This page applies Concurrent Index Builds on Spatial Tables to the hardest common case: a land_parcels table with 100 million polygon rows in EPSG:32633 (WGS 84 / UTM zone 33N) that has never had a spatial index, and that cannot be taken offline because field crews write to it around the clock. The requirement is a GiST index on geom with zero write downtime, a way to watch a build that will run for hours, and a clean recovery path if it dies partway.

Why the Naive Approach Fails

The instinct is to run the plain build during a “quiet” window:

sql
-- Wrong at this scale: freezes every write for the whole multi-hour build
CREATE INDEX idx_land_parcels_geom
ON land_parcels
USING GIST (geom);

On 100 million geometries this holds a SHARE lock for hours. SHARE conflicts with the ROW EXCLUSIVE lock that every INSERT and UPDATE needs, so field-crew writes back up behind the build and the application times out. There is no quiet window long enough. Running it from Python inside a transaction fails differently but just as hard:

python
# Wrong: CREATE INDEX CONCURRENTLY errors inside an implicit transaction
import psycopg

with psycopg.connect("dbname=cadastre") as conn:   # autocommit is False by default
    with conn.cursor() as cur:
        cur.execute(
            "CREATE INDEX CONCURRENTLY idx_land_parcels_geom "
            "ON land_parcels USING GIST (geom)"
        )
    # -> psycopg.errors.ActiveSqlTransaction:
    #    CREATE INDEX CONCURRENTLY cannot run inside a transaction block

psycopg opens an implicit transaction for the statement, and the concurrent build refuses to run inside one. The fix is autocommit plus a build launched on its own connection, monitored from a second.

Production-Ready Implementation

The script launches the concurrent build on a dedicated autocommit connection, then polls progress and validity from a separate connection until the index is confirmed valid. It also cleans up a pre-existing INVALID index before starting, so a previous failed attempt does not block the retry.

python
"""Zero-downtime GiST build on a 100M-row land_parcels table (EPSG:32633).

Launches CREATE INDEX CONCURRENTLY on an autocommit connection and monitors
pg_stat_progress_create_index + pg_index.indisvalid from a second connection.
"""
import time
import logging
import psycopg

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("cic")

DSN = "host=db-primary dbname=cadastre user=migrator"
TABLE = "land_parcels"
INDEX = "idx_land_parcels_geom"


def drop_invalid_index(conn: psycopg.Connection) -> None:
    """Remove a leftover INVALID index from a prior failed build, if any."""
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT c.relname
            FROM pg_index i
            JOIN pg_class c ON c.oid = i.indexrelid
            WHERE i.indrelid = %s::regclass
              AND c.relname = %s
              AND i.indisvalid = false
            """,
            (TABLE, INDEX),
        )
        if cur.fetchone():
            log.warning("dropping leftover INVALID index %s", INDEX)
            cur.execute(f"DROP INDEX CONCURRENTLY {INDEX}")


def launch_build(conn: psycopg.Connection) -> None:
    """Issue the concurrent build. conn MUST be autocommit."""
    with conn.cursor() as cur:
        cur.execute("SET maintenance_work_mem = '2GB'")
        cur.execute("SET max_parallel_maintenance_workers = 4")
        log.info("starting CREATE INDEX CONCURRENTLY on %s", TABLE)
        cur.execute(
            f"CREATE INDEX CONCURRENTLY {INDEX} "
            f"ON {TABLE} USING GIST (geom)"
        )
        log.info("build statement returned")


def poll_progress(monitor: psycopg.Connection) -> None:
    """Print live phase/percent until no build is in flight."""
    while True:
        with monitor.cursor() as cur:
            cur.execute(
                """
                SELECT phase, blocks_done, blocks_total,
                       round(100.0 * blocks_done / NULLIF(blocks_total, 0), 1) AS pct
                FROM pg_stat_progress_create_index
                WHERE relid = %s::regclass
                """,
                (TABLE,),
            )
            row = cur.fetchone()
        if row is None:
            break
        phase, done, total, pct = row
        log.info("phase=%s blocks=%s/%s (%s%%)", phase, done, total, pct)
        time.sleep(10)


def confirm_valid(monitor: psycopg.Connection) -> bool:
    with monitor.cursor() as cur:
        cur.execute(
            """
            SELECT i.indisvalid, i.indisready
            FROM pg_index i
            JOIN pg_class c ON c.oid = i.indexrelid
            WHERE i.indrelid = %s::regclass AND c.relname = %s
            """,
            (TABLE, INDEX),
        )
        row = cur.fetchone()
    return bool(row and row[0] and row[1])


def main() -> None:
    # Separate monitor connection so polling never shares the build's session.
    with psycopg.connect(DSN, autocommit=True) as monitor:
        with psycopg.connect(DSN, autocommit=True) as builder:
            drop_invalid_index(builder)
            # Run the (blocking-in-Python) build; poll from the monitor meanwhile
            # by using a background thread or, more simply, launch the build and
            # poll after it returns. Here we poll after launch returns because
            # the statement blocks this connection until the build finishes.
            launch_build(builder)
        # Once launch returns, the build is done or failed; verify.
        if confirm_valid(monitor):
            with monitor.cursor() as cur:
                cur.execute(f"ANALYZE {TABLE}")
            log.info("index %s is VALID; table analyzed", INDEX)
        else:
            log.error("index %s is INVALID; run drop_invalid_index and retry", INDEX)


if __name__ == "__main__":
    main()

Because the CREATE INDEX CONCURRENTLY statement blocks its own connection until the build completes, run poll_progress(monitor) from a genuinely concurrent context — a separate thread, process, or a second terminal session running the progress query — while launch_build is in flight. The monitoring SQL is standalone:

sql
-- Run repeatedly from a second session while the build runs
SELECT phase, blocks_done, blocks_total,
       round(100.0 * blocks_done / NULLIF(blocks_total, 0), 1) AS pct,
       (now() - a.query_start) AS elapsed
FROM pg_stat_progress_create_index p
JOIN pg_stat_activity a USING (pid)
WHERE p.relid = 'land_parcels'::regclass;

Configuration and Tuning Knobs

  • maintenance_work_mem = '2GB' (or higher on a large host). The GiST sort phase spills to temp files when this is too small; more memory means fewer spills and a faster build. Set it on the builder session, not globally.
  • max_parallel_maintenance_workers = 4. Parallel workers accelerate the scan and sort on a 100M-row table. Ensure max_worker_processes and max_parallel_workers are high enough for the requested workers to actually launch.
  • A dedicated autocommit connection for the build. Never share it with application traffic and never wrap it in a transaction; that is the difference between a clean build and the ActiveSqlTransaction error above.
  • statement_timeout = 0 on the builder session. A hours-long build must not be killed by an inherited timeout; disable it explicitly for that connection only.
  • Idle-transaction hygiene. The build waits for every transaction older than itself before validating. Set idle_in_transaction_session_timeout on the application roles so a forgotten open transaction cannot stall the build indefinitely.

Verification Steps

sql
-- 1. Index is valid and ready
SELECT c.relname, i.indisvalid, i.indisready
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'land_parcels'::regclass
  AND c.relname = 'idx_land_parcels_geom';        -- both true

-- 2. Index size is sane for the row count
SELECT pg_size_pretty(pg_relation_size('idx_land_parcels_geom'));

-- 3. Planner uses it against a same-SRID envelope (EPSG:32633)
EXPLAIN (ANALYZE, BUFFERS)
SELECT parcel_id
FROM land_parcels
WHERE geom && ST_MakeEnvelope(380000, 5810000, 385000, 5815000, 32633);
-- Expect Bitmap Index Scan on idx_land_parcels_geom, not Seq Scan

A Python assertion for CI or a migration runner:

python
import psycopg

with psycopg.connect(DSN, autocommit=True) as conn, conn.cursor() as cur:
    cur.execute(
        """
        SELECT i.indisvalid
        FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
        WHERE i.indrelid = 'land_parcels'::regclass
          AND c.relname = 'idx_land_parcels_geom'
        """
    )
    row = cur.fetchone()
    assert row and row[0], "GiST index is missing or INVALID"
    print("idx_land_parcels_geom is valid")

Gotchas Checklist

  • Autocommit is mandatory. Without it the build never even starts — psycopg raises CREATE INDEX CONCURRENTLY cannot run inside a transaction block the moment it opens its implicit transaction.
  • Always drop a failed index with DROP INDEX CONCURRENTLY. A plain DROP INDEX takes a blocking lock, reintroducing the very downtime the concurrent build avoided.
  • Match the envelope SRID to the column. An ST_MakeEnvelope(..., 4326) probe against a 32633 column forces an implicit transform and hides the new index behind a Seq Scan; build test envelopes in EPSG:32633.
  • Watch for the waiting for old snapshots phase. A single long-idle transaction can stall validation for the whole table; enforce idle_in_transaction_session_timeout before you start.
  • Budget the disk. A concurrent build needs room for the finished index plus temp sort files simultaneously; on 100M polygon rows that can be tens of gigabytes of transient space.