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:
-- 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:
# 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 blockpsycopg 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.
"""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:
-- 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. Ensuremax_worker_processesandmax_parallel_workersare 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
ActiveSqlTransactionerror above. statement_timeout = 0on 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_timeouton the application roles so a forgotten open transaction cannot stall the build indefinitely.
Verification Steps
-- 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 ScanA Python assertion for CI or a migration runner:
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 blockthe moment it opens its implicit transaction. - Always drop a failed index with
DROP INDEX CONCURRENTLY. A plainDROP INDEXtakes 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 aSeq Scan; build test envelopes in EPSG:32633. - Watch for the
waiting for old snapshotsphase. A single long-idle transaction can stall validation for the whole table; enforceidle_in_transaction_session_timeoutbefore 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.
Related Topics
- Concurrent Index Builds on Spatial Tables — parent: lock semantics, REINDEX CONCURRENTLY, and INVALID-index theory
- Spatial Schema Migrations & Evolution — the broader online schema-change toolkit
- In-Place SRID Reprojection — why a fresh GiST build is required after rewriting a geometry column’s CRS
- Detecting GiST Index Bloat — deciding when to rebuild the index you just created