Introducing a geometry column is the most common step in spatial schema migrations and evolution, and on a table that is quietly serving thousands of inserts a minute it is also where a careless ALTER TABLE does the most damage. A transit platform whose stops table has stored latitude and longitude as plain doubles since launch eventually needs a real geom column so it can run ST_DWithin proximity queries and join against rail_segments; a segments table that has only ever held endpoint coordinates needs a shape LineString before any routing query can touch it. This guide walks the full sequence for getting a typed, SRID-tagged, indexed, constraint-guarded geometry column onto a live table without ever holding a lock long enough to matter.

The mistake that causes outages is treating the whole thing as one statement. Adding the column, populating it, constraining it, and indexing it each have a different lock profile, and the safe path deliberately separates them so that no single step scans the entire heap while holding a lock that blocks writers.


Column-addition flow on a live table

The diagram traces the four distinct operations and the lock each one takes, from the catalog-only column addition through to the concurrent index build. Reading it top to bottom is the exact order to run the migration.

Adding a geometry column: step and lock profile Four stacked stages left to right connected by arrows. Stage 1: ALTER TABLE ADD COLUMN nullable, brief ACCESS EXCLUSIVE lock. Stage 2: batched backfill UPDATE, row locks only. Stage 3: NOT VALID then VALIDATE constraint, SHARE UPDATE EXCLUSIVE. Stage 4: CREATE INDEX CONCURRENTLY gist, SHARE UPDATE EXCLUSIVE. Under each box a caption names the lock. 1. ADD COLUMN geometry(Point,4326) nullable, no default brief ACCESS EXCL. 2. Backfill batched UPDATE WHERE geom IS NULL row locks only 3. Constrain SRID + ST_IsValid NOT VALID → VALIDATE SHARE UPDATE EXCL. 4. Index CREATE INDEX CONCURRENTLY gist SHARE UPDATE EXCL. stops — live table, inserts never stop

Prerequisites and infrastructure validation

Confirm the PostGIS version, inspect the target table, and record what already exists before touching anything. A geometry column requires the postgis extension; the concurrent index build later requires that you are not inside a transaction.

Confirm PostGIS is present and recent enough:

sql
-- Any 3.x is fine; geometry_columns has been a live view since 2.0.
SELECT PostGIS_Full_Version();

Inspect the table you are about to alter — its row count drives your batch size and whether a concurrent index build is worth the extra time:

sql
-- Approximate row count (fast; reads pg_class, not the heap)
SELECT reltuples::bigint AS approx_rows
FROM   pg_class
WHERE  relname = 'stops';

-- What geometry columns already exist on this table, if any?
SELECT f_geometry_column, type, srid
FROM   geometry_columns
WHERE  f_table_name = 'stops';

Python packages — pin the driver and migration tooling so behaviour is reproducible across CI and production:

bash
pip install "psycopg[binary]>=3.1" "sqlalchemy>=2.0,<3" "geoalchemy2>=0.14" alembic

Confirm the GiST operator class is installed with a throwaway table, so a mid-migration surprise does not abort the index build:

sql
CREATE TEMP TABLE _gist_probe (g geometry(Point, 4326));
CREATE INDEX ON _gist_probe USING gist (g);   -- no error => operator class present
DROP TABLE _gist_probe;

Step 1 — Add the typed column without a rewrite

Add the column as nullable with no default. Typing it geometry(Point, 4326) writes the geometry type and SRID into the column’s type modifier, which is exactly what the geometry_columns view reads and what lets PostGIS reject a LineString or a mismatched SRID at write time. Because there is no default, PostgreSQL edits only the catalog and returns almost immediately even on a table with tens of millions of rows.

sql
-- Metadata-only change. lock_timeout makes the brief lock fail fast if blocked.
SET lock_timeout = '3s';
ALTER TABLE stops
    ADD COLUMN geom geometry(Point, 4326);

Wrapping this in Alembic keeps it versioned alongside the rest of the schema history:

python
from alembic import op

def upgrade() -> None:
    op.execute("SET lock_timeout = '3s'")
    op.execute("ALTER TABLE stops ADD COLUMN geom geometry(Point, 4326)")

def downgrade() -> None:
    op.execute("ALTER TABLE stops DROP COLUMN geom")

The reason a bare ADD COLUMN geom geometry is wrong is subtle: it succeeds, but the column accepts any geometry type and any SRID, and geometry_columns reports its type as GEOMETRY and its SRID as 0. Every downstream ST_DWithin or spatial join that assumes SRID 4326 then breaks or silently skips the index. Always carry the type modifier. The narrow, high-traffic variant of this step — where even the brief lock is a concern and the exact lock-avoidance sequencing matters most — is covered in ALTER TABLE ADD COLUMN geometry without locking.


Step 2 — Confirm registration in geometry_columns

Modern PostGIS makes geometry_columns a view over the catalog, so registration is automatic. Verify it rather than assume it — a 0 SRID here is the earliest, cheapest place to catch a mistyped column:

sql
SELECT f_table_name, f_geometry_column, coord_dimension, srid, type
FROM   geometry_columns
WHERE  f_table_name = 'stops' AND f_geometry_column = 'geom';
-- Expected: stops | geom | 2 | 4326 | POINT

If this returns 4326 and POINT, the type modifier took effect and you never need the legacy AddGeometryColumn() function. That function still exists for backward compatibility but layers on a redundant enforce_srid_geom CHECK constraint that duplicates what the type modifier already guarantees.


Step 3 — Backfill the geometry in bounded batches

The column exists but every row holds NULL. Populate it from the existing latitude/longitude columns in batches, each its own transaction, so no single statement locks a large row set or bloats the table with one giant burst of dead tuples.

sql
-- One batch; repeat until it affects zero rows.
WITH batch AS (
    SELECT stop_id
    FROM   stops
    WHERE  geom IS NULL
    ORDER  BY stop_id
    LIMIT  5000
    FOR UPDATE SKIP LOCKED
)
UPDATE stops s
SET    geom = ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)
FROM   batch
WHERE  s.stop_id = batch.stop_id;

Note the argument order in ST_MakePoint(longitude, latitude): PostGIS takes X (longitude) first. Reversing them is the single most common backfill bug — it silently produces points off the coast of West Africa where the equator meets the prime meridian, and nothing errors because both values are valid coordinates.

The Python driver loops the batch, commits each one, and pauses to protect replica lag:

python
import time
import psycopg

BATCH = """
WITH batch AS (
    SELECT stop_id FROM stops WHERE geom IS NULL
    ORDER BY stop_id LIMIT %(n)s FOR UPDATE SKIP LOCKED
)
UPDATE stops s
SET geom = ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)
FROM batch WHERE s.stop_id = batch.stop_id
"""

def backfill_stops(conn_str: str, n: int = 5000) -> int:
    total = 0
    with psycopg.connect(conn_str) as conn:
        while True:
            with conn.cursor() as cur:
                cur.execute(BATCH, {"n": n})
                rows = cur.rowcount
            conn.commit()
            total += rows
            if rows == 0:
                return total
            time.sleep(0.1)

For the full zero-downtime treatment — keeping newly inserted rows populated with a trigger while this historical backfill runs so the two never race — see backfilling and zero-downtime migrations.


Step 4 — Attach SRID and validity constraints

The type modifier already enforces POINT and SRID 4326 for new writes, but an explicit SRID CHECK documents intent and guards against a future ALTER that loosens the type. A validity constraint additionally rejects degenerate geometry. Add both NOT VALID first, then validate, so the row-scanning half runs under a lock that lets writers continue:

sql
-- Step 4a: brief lock, no scan. Applies to new writes immediately.
ALTER TABLE stops
    ADD CONSTRAINT chk_stops_geom_srid
    CHECK (ST_SRID(geom) = 4326) NOT VALID;

ALTER TABLE stops
    ADD CONSTRAINT chk_stops_geom_valid
    CHECK (geom IS NULL OR ST_IsValid(geom)) NOT VALID;

-- Step 4b: scans existing rows under SHARE UPDATE EXCLUSIVE (writers proceed).
ALTER TABLE stops VALIDATE CONSTRAINT chk_stops_geom_srid;
ALTER TABLE stops VALIDATE CONSTRAINT chk_stops_geom_valid;

For point data ST_IsValid is almost always true, but on a rail_segments.shape LineString it catches zero-length segments and repeated vertices that would otherwise poison length and routing calculations. If validation fails, find and repair the offenders before retrying:

sql
-- Locate invalid geometries and see why they fail
SELECT stop_id, ST_IsValidReason(geom)
FROM   stops
WHERE  NOT ST_IsValid(geom)
LIMIT  20;

Step 5 — Build the GiST index concurrently

With the column populated and constrained, build the spatial index. A plain CREATE INDEX would lock stops against writes for the whole build; the concurrent form does two lighter heap passes under a SHARE UPDATE EXCLUSIVE lock that permits concurrent inserts and updates. It must run outside a transaction block:

sql
-- Run this outside any explicit transaction (not inside BEGIN/COMMIT).
CREATE INDEX CONCURRENTLY idx_stops_geom
    ON stops USING gist (geom);

From Alembic, this needs a connection in autocommit mode because the default per-revision transaction would reject it:

python
from alembic import op

def upgrade() -> None:
    # Escape Alembic's transaction: switch the raw connection to AUTOCOMMIT.
    conn = op.get_bind()
    conn.execution_options(isolation_level="AUTOCOMMIT")
    conn.exec_driver_sql(
        "CREATE INDEX CONCURRENTLY idx_stops_geom ON stops USING gist (geom)"
    )

The concurrent build’s failure modes — an interrupted build that leaves an INVALID index needing manual cleanup, and how to monitor build progress on a huge table — are the focus of concurrent index builds. The wider question of which index type and which columns to combine belongs to advanced GiST indexing and optimization.


Performance considerations

Confirming the planner uses the new index

After the build, refresh statistics and confirm the planner chooses an index scan for a bounding-box query. Without a fresh ANALYZE, the planner works from stale row estimates and may ignore a perfectly good index:

sql
ANALYZE stops;

EXPLAIN (ANALYZE, BUFFERS)
SELECT stop_id
FROM   stops
WHERE  geom && ST_MakeEnvelope(-122.42, 37.74, -122.38, 37.80, 4326);

A healthy plan reads:

Index Scan using idx_stops_geom on stops
  (cost=0.29..42.10 rows=48 width=8)
  (actual time=0.05..0.21 rows=51 loops=1)
  Index Cond: (geom && '...'::geometry)
Buffers: shared hit=7

Seeing Seq Scan instead points to one of three things: statistics are stale (the ANALYZE above fixes it), the index is INVALID from an interrupted concurrent build (check pg_index.indisvalid), or the table is still small enough that a scan is genuinely cheaper.

GUCs that shape the migration’s cost

Setting Value during migration Why
lock_timeout 3s Brief-lock DDL fails fast instead of stalling writers
maintenance_work_mem 1GB Larger memory speeds the GiST build substantially
random_page_cost 1.1 (SSD) Nudges the planner toward the new index scan
autovacuum_vacuum_scale_factor 0.02 (on this table) Reclaims backfill bloat sooner

Raise maintenance_work_mem only for the session running the index build; a large global value multiplied across many autovacuum workers can exhaust memory:

sql
SET maintenance_work_mem = '1GB';

Common failure modes and fixes

The column reports SRID 0

Symptom: geometry_columns shows srid = 0 and spatial joins skip the index.

Cause: the column was added as bare geometry with no type modifier.

Fix: re-type the column so the modifier is applied. On a populated column this rewrites it, so schedule it or, better, drop and re-add before backfilling:

sql
ALTER TABLE stops
    ALTER COLUMN geom TYPE geometry(Point, 4326)
    USING ST_SetSRID(geom, 4326);

VALIDATE CONSTRAINT fails on existing rows

Symptom: ERROR: check constraint "chk_stops_geom_valid" is violated by some row.

Diagnosis: locate the offending geometries with ST_IsValidReason, as shown in Step 4. Self-intersections and duplicated vertices are the usual causes on line and polygon data.

Fix: repair in place with ST_MakeValid, then retry the validation:

sql
UPDATE stops
SET    geom = ST_MakeValid(geom)
WHERE  NOT ST_IsValid(geom);

CREATE INDEX CONCURRENTLY leaves an invalid index

Symptom: the build was cancelled or the session dropped, and now an index exists but is never used.

Diagnosis:

sql
SELECT c.relname, i.indisvalid
FROM   pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE  c.relname = 'idx_stops_geom';
-- indisvalid = false means the build did not complete

Fix: drop the failed index (concurrently, to avoid a lock) and rebuild:

sql
DROP INDEX CONCURRENTLY idx_stops_geom;
CREATE INDEX CONCURRENTLY idx_stops_geom ON stops USING gist (geom);

The backfill inflates the table and slows everything

Symptom: table and index size balloon during a large backfill; scans get slower as it runs.

Cause: each updated row leaves a dead tuple and autovacuum is not keeping up.

Fix: tighten autovacuum on the table for the duration and vacuum once at the end:

sql
ALTER TABLE stops SET (autovacuum_vacuum_scale_factor = 0.02);
-- After the backfill completes:
VACUUM (ANALYZE) stops;

Verification

Run this end-to-end check after all steps complete to confirm the column is typed, registered, constrained, indexed, and index-using:

sql
-- 1. Column registered with correct type and SRID
SELECT f_geometry_column, type, srid
FROM   geometry_columns
WHERE  f_table_name = 'stops';
-- Expected: geom | POINT | 4326

-- 2. No rows left unpopulated
SELECT count(*) AS unpopulated FROM stops WHERE geom IS NULL;
-- Expected: 0

-- 3. Constraints present and validated
SELECT conname, convalidated
FROM   pg_constraint
WHERE  conrelid = 'stops'::regclass AND conname LIKE 'chk_stops_geom%';
-- Expected: both rows convalidated = true

-- 4. Index built and valid
SELECT c.relname, i.indisvalid
FROM   pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE  c.relname = 'idx_stops_geom';
-- Expected: idx_stops_geom | true

-- 5. Planner uses it
EXPLAIN (ANALYZE, BUFFERS)
SELECT stop_id FROM stops
WHERE  geom && ST_MakeEnvelope(-122.42, 37.74, -122.38, 37.80, 4326);
-- Expected: Index Scan using idx_stops_geom

All five passing means the column is production-ready and the migration held no long lock at any point.


Frequently Asked Questions

Do I still need AddGeometryColumn() in modern PostGIS?

No. Since PostGIS 2.0 the geometry_columns view is a live catalog view, so a plain ALTER TABLE stops ADD COLUMN geom geometry(Point, 4326) registers automatically with the correct type and SRID. The legacy AddGeometryColumn() function still works but layers on a redundant SRID CHECK constraint and is retained mainly for backward compatibility with pre-2.0 scripts.

Why does my new geometry column show up as type GEOMETRY with SRID 0?

The column was declared as bare geometry without a type modifier. Declare it as geometry(Point, 4326) so the type and SRID are stored in the column’s type modifier. A bare geometry column accepts any geometry type and any SRID, and geometry_columns therefore reports its type as GEOMETRY and its SRID as 0, which disables SRID-dependent index use.

Can I add the ST_IsValid check constraint in a single statement?

You can, but on a large table it holds a lock while it scans every existing row to verify them. Add the constraint with NOT VALID first so it applies to new writes under a brief lock, then run VALIDATE CONSTRAINT separately; the validation scan runs under a weaker SHARE UPDATE EXCLUSIVE lock that allows concurrent inserts and updates to proceed.

Should I create the GiST index before or after backfilling?

After. Building the index while the column is empty and then having the backfill maintain it doubles the write cost of every batch and produces a more bloated index. Populate the column fully first, then build the index once with CREATE INDEX CONCURRENTLY so it is constructed from the final data in a single clean pass.