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

Concurrent builds cannot live inside a transaction Three attempts. Running the statement inside a BEGIN block raises an error immediately. Running it through a migration tool that wraps each revision in a transaction fails the same way. Running it on an autocommit connection succeeds. Each case shows the exact error or outcome. The transaction wrapper your tool adds for you BEGIN; CREATE INDEX CONCURRENTLY …; COMMIT; ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block op.execute("CREATE INDEX CONCURRENTLY …") # inside an Alembic revision same error — Alembic opens a transaction per revision by default with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as c: works — and the build survives a client disconnect only if you keep the session alive

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

Watching a build that has no progress bar of its own The phases reported by pg_stat_progress_create_index for a GiST build on ninety million rows, with the share of wall-clock each one took: waiting for old snapshots, building the index, loading tuples, waiting again, validating, and the final catalogue update. Phases of a 94-minute GiST build on 90M rows waiting for writers 3 min building index 41 min — the real work waiting again 6 min validating 44 min — the second scan SELECT phase, blocks_done, blocks_total FROM pg_stat_progress_create_index; The validation scan is as expensive as the build. Budget double what a plain CREATE INDEX would have cost.

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

Three checks before you call the build done Three checks in sequence. First confirm indisvalid and indisready are both true in pg_index. Second confirm idx_scan is climbing in pg_stat_user_indexes, which proves the planner chose it. Third compare pg_relation_size against the expected range for the row count, to catch a build that silently indexed the wrong expression. Valid, used, and the right size 1 · valid indisvalid = true indisready = true both, not just one — a half-built index sets one 2 · used idx_scan > 0 and climbing an unused new index means the predicate does not match 3 · sized right pg_relation_size() roughly 70–90 bytes per row for a 2D GiST index — an order out means trouble Only after all three pass should the old index be dropped, and drop it concurrently as well.
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.