This page addresses one narrow, high-stakes case within adding geometry columns to live tables: a rail_segments table with a hundred million rows, thousands of inserts a minute, and no maintenance window, onto which you must add a shape geometry column without ever holding an ACCESS EXCLUSIVE lock long enough to block the write path. At this scale the difference between a metadata-only ALTER and a heap-rewriting one is the difference between a non-event and a paging incident.
Why the naive approach fails
The instinct is to add the column already populated, in one statement, so the schema is “done” immediately:
-- DANGEROUS on a large, high-traffic table.
ALTER TABLE rail_segments
ADD COLUMN shape geometry(LineString, 4326)
DEFAULT ST_SetSRID(ST_MakeLine(
ST_MakePoint(start_lon, start_lat),
ST_MakePoint(end_lon, end_lat)), 4326);This is a table rewrite. A non-constant default forces PostgreSQL to compute shape for every one of the hundred million existing rows, and it does so while holding ACCESS EXCLUSIVE — the strongest lock, which blocks reads and writes — for the entire rewrite. On a large table that is minutes of total outage, and worse, the moment the statement starts waiting for the lock it queues behind any in-flight query and then blocks every transaction that arrives after it. A second failure hides in it too: even a plain ADD COLUMN ... DEFAULT with a constant is safe in modern PostgreSQL, but the instant the default is an expression over other columns, the fast-path optimization is disabled and the rewrite is unavoidable.
The fix is to separate the fast catalog change from the slow data change, and to make the slow part a sequence of small, interruptible steps that never hold a strong lock.
Production-ready implementation
The following is the complete, copy-paste sequence. Each phase is independently committed and resumable, and no phase holds a lock that blocks the write path for more than a few milliseconds.
import time
import psycopg
CONN = "postgresql://migrator@primary:5432/transit"
# ── Phase 1: catalog-only column add, bounded lock wait ──────────────────────
def add_column(conn_str: str) -> None:
with psycopg.connect(conn_str, autocommit=True) as conn:
with conn.cursor() as cur:
# Fail fast if the brief ACCESS EXCLUSIVE lock cannot be had in 3s,
# so we never queue behind a long transaction and stall writers.
cur.execute("SET lock_timeout = '3s'")
cur.execute(
"ALTER TABLE rail_segments "
"ADD COLUMN shape geometry(LineString, 4326)" # nullable, no default
)
# ── Phase 2: batched backfill; each batch its own short transaction ──────────
BACKFILL = """
WITH batch AS (
SELECT segment_id
FROM rail_segments
WHERE shape IS NULL
ORDER BY segment_id
LIMIT %(n)s
FOR UPDATE SKIP LOCKED -- step over rows a writer is holding
)
UPDATE rail_segments r
SET shape = ST_SetSRID(
ST_MakeLine(ST_MakePoint(r.start_lon, r.start_lat),
ST_MakePoint(r.end_lon, r.end_lat)),
4326)
FROM batch
WHERE r.segment_id = batch.segment_id
"""
def backfill(conn_str: str, batch_size: int = 3000, pause: float = 0.15) -> int:
total = 0
with psycopg.connect(conn_str) as conn:
cur = conn.cursor()
cur.execute("SET statement_timeout = '30s'") # cap a pathological batch
while True:
cur.execute(BACKFILL, {"n": batch_size})
affected = cur.rowcount
conn.commit() # release row locks each batch
total += affected
if affected == 0:
return total
time.sleep(pause) # throttle for replica lag
# ── Phase 3: constraints NOT VALID, then validate under a weaker lock ─────────
def add_constraints(conn_str: str) -> None:
with psycopg.connect(conn_str, autocommit=True) as conn:
cur = conn.cursor()
cur.execute("SET lock_timeout = '3s'")
cur.execute(
"ALTER TABLE rail_segments ADD CONSTRAINT chk_rail_shape_srid "
"CHECK (ST_SRID(shape) = 4326) NOT VALID"
)
cur.execute(
"ALTER TABLE rail_segments ADD CONSTRAINT chk_rail_shape_valid "
"CHECK (shape IS NULL OR ST_IsValid(shape)) NOT VALID"
)
# VALIDATE scans rows under SHARE UPDATE EXCLUSIVE; writers proceed.
cur.execute("ALTER TABLE rail_segments VALIDATE CONSTRAINT chk_rail_shape_srid")
cur.execute("ALTER TABLE rail_segments VALIDATE CONSTRAINT chk_rail_shape_valid")
# ── Phase 4: GiST index built concurrently, outside any transaction ──────────
def build_index(conn_str: str) -> None:
# autocommit is mandatory: CREATE INDEX CONCURRENTLY rejects a txn block.
with psycopg.connect(conn_str, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(
"CREATE INDEX CONCURRENTLY idx_rail_segments_shape "
"ON rail_segments USING gist (shape)"
)
if __name__ == "__main__":
add_column(CONN)
backfill(CONN)
add_constraints(CONN)
build_index(CONN)Phase 1 returns in milliseconds because a nullable column with no default only edits the catalog. Phase 2 never locks more than batch_size rows at once and commits each batch, so autovacuum can reclaim dead tuples between iterations and the write path is never blocked. Phase 3 pays the row-scan cost of validation under SHARE UPDATE EXCLUSIVE, which permits concurrent inserts and updates. Phase 4 builds the spatial index without a write lock. The batched-backfill mechanics here — the keyset window, SKIP LOCKED, and derivation from the endpoint columns — are the same pattern generalised in backfilling geometry from latitude/longitude columns, and the concurrent build has its own deeper treatment in CREATE INDEX CONCURRENTLY on large spatial tables.
Configuration and tuning knobs
lock_timeout. The most important knob for Phases 1 and 3. Set it to 3–5 seconds so a brief-lock DDL that cannot acquire its lock fails fast and can be retried, rather than queueing behind a long transaction and blocking every writer that arrives after it. Wrap each locking statement in a retry loop with exponential backoff.
batch_size. Governs the trade-off between throughput and contention in Phase 2. On a high-traffic table, 2,000–5,000 rows per batch keeps each transaction short. Watch pg_stat_replication lag; if replicas fall behind, lower the batch size or raise the inter-batch pause.
statement_timeout on the backfill session. Caps a single batch so an unexpected sequential scan cannot run unbounded. Thirty seconds is generous for a well-indexed keyset batch and still catches pathological cases.
maintenance_work_mem for Phase 4. Raise it for the index-building session only — SET maintenance_work_mem = '1GB' — to speed the GiST build. Keep it session-scoped so it does not multiply across autovacuum workers.
Autovacuum on the table. During the backfill, tighten it so dead tuples from the updates are reclaimed promptly:
ALTER TABLE rail_segments SET (autovacuum_vacuum_scale_factor = 0.02);Verification steps
Confirm each phase landed before relying on the column:
-- Column registered with the intended type and SRID
SELECT type, srid FROM geometry_columns
WHERE f_table_name = 'rail_segments' AND f_geometry_column = 'shape';
-- Expected: LINESTRING | 4326
-- Backfill complete — no NULLs remain
SELECT count(*) FROM rail_segments WHERE shape IS NULL; -- Expected: 0
-- Both constraints validated
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'rail_segments'::regclass AND conname LIKE 'chk_rail_shape%';
-- Index built and valid (an interrupted build leaves indisvalid = false)
SELECT c.relname, i.indisvalid
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'idx_rail_segments_shape';
-- Planner uses it after a fresh ANALYZE
ANALYZE rail_segments;
EXPLAIN (ANALYZE, BUFFERS)
SELECT segment_id FROM rail_segments
WHERE shape && ST_MakeEnvelope(-122.42, 37.74, -122.38, 37.80, 4326);
-- Expected: Index Scan using idx_rail_segments_shapeWhile the migration runs, keep an eye on lock waits it may be creating so you can abort before it affects traffic:
SELECT pid, wait_event_type, state, left(query, 50) AS query
FROM pg_stat_activity
WHERE query ILIKE '%rail_segments%' AND pid <> pg_backend_pid();Gotchas checklist
- An expression default triggers a full rewrite.
ADD COLUMN shape geometry(...) DEFAULT ST_MakeLine(...)rewrites every row underACCESS EXCLUSIVE. Add the column nullable with no default and backfill separately — always. - Reversed coordinate order.
ST_MakePointtakes X then Y, i.e. longitude then latitude. Passing(lat, lon)produces valid-but-wrong geometry that no constraint catches. Double-check the argument order against a known stop before running the full backfill. - Forgetting autocommit for the concurrent build.
CREATE INDEX CONCURRENTLYerrors out inside a transaction block. The psycopg connection must be opened withautocommit=True, and an Alembic revision must switch the bind toAUTOCOMMITisolation. - Skipping the final ANALYZE. A freshly built index is invisible to the planner until statistics are refreshed; a query that still shows
Seq Scanright after the build usually just needsANALYZE rail_segments. - Leaving a failed concurrent build in place. An interrupted
CREATE INDEX CONCURRENTLYleaves anINVALIDindex that consumes space and is never used. Checkindisvalid, and if false,DROP INDEX CONCURRENTLYbefore rebuilding.
Related Topics
- Adding Geometry Columns to Live Tables — parent guide covering the full column-addition workflow and its lock profiles
- Backfilling Geometry from Latitude/Longitude Columns — the batched backfill pattern generalised, including dual-write triggers
- CREATE INDEX CONCURRENTLY on Large Spatial Tables — deep dive on the concurrent GiST build and invalid-index recovery
- Spatial Schema Migrations & Evolution — the top-level guide to evolving live PostGIS schemas without downtime