Problem Statement
The revision adds a geometry column and needs a GiST index on it. Written the obvious way, the migration takes an exclusive lock for the forty minutes the build takes, and the deploy becomes an outage. CREATE INDEX CONCURRENTLY solves that — and cannot run inside the transaction Alembic wraps around every revision. This page covers the two supported escapes and the error handling that makes either one safe to re-run.
Why the Naive Approach Fails
def upgrade() -> None:
op.execute("CREATE INDEX CONCURRENTLY idx_listings_geom "
"ON listings USING gist (geom)")sqlalchemy.exc.InternalError: (psycopg.errors.ActiveSqlTransaction)
CREATE INDEX CONCURRENTLY cannot run inside a transaction block
The error is precise and the cause is not in your code — Alembic opened the transaction before your revision’s upgrade() was called. Removing the CONCURRENTLY keyword makes the error go away and reintroduces the lock, which is how most teams end up shipping the locking version by accident.
Production-Ready Implementation
The autocommit block, with the two pieces of defensive handling that make it re-runnable:
"""add geom to listings and index it concurrently
Revision ID: 8c41f0a2e7b3
"""
from alembic import op
import sqlalchemy as sa
import geoalchemy2 # noqa: F401
from geoalchemy2 import Geometry
revision = "8c41f0a2e7b3"
down_revision = "5f9b1c7d0a44"
INDEX = "idx_listings_geom"
TABLE = "listings"
def upgrade() -> None:
# 1. the transactional part: the column itself is a catalogue-only change
op.add_column(TABLE, sa.Column("geom", Geometry("POINT", srid=4326,
spatial_index=False),
nullable=True))
# 2. the concurrent part, on a connection with no open transaction
conn = op.get_bind()
conn.execute(sa.text("COMMIT")) # close Alembic's transaction
# a previous interrupted run may have left an INVALID index behind
conn.execute(sa.text(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX}"))
conn.execute(sa.text(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX} "
f"ON {TABLE} USING gist (geom)"
))
# 3. refuse to report success on a half-built index
valid = conn.execute(sa.text(
"SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:n)"
), {"n": INDEX}).scalar()
if not valid:
raise RuntimeError(f"{INDEX} was created but is INVALID — rebuild it")
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("COMMIT"))
conn.execute(sa.text(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX}"))
op.drop_column(TABLE, "geom")Three details carry the weight. spatial_index=False on the column stops GeoAlchemy2 creating its own locking index as a side effect — without it you get the very lock you are trying to avoid, before the concurrent build even starts. The explicit COMMIT ends Alembic’s transaction so the following statements run in autocommit. And the indisvalid check turns a silent half-failure into a failed migration, which is the difference between finding out now and finding out when the table slows down next month.
The non-transactional revision alternative
When a whole revision consists of concurrent operations, disabling the wrapper is cleaner than committing inside it:
# alembic/env.py
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True, # each revision gets its own transaction
transactional_ddl=False, # …and DDL is not wrapped
)This is a global setting, so it changes the guarantees for every migration in the project: a revision that fails halfway no longer rolls back. That is acceptable when every revision is written to be idempotent and re-runnable, and dangerous otherwise. Choose it deliberately, at the project level, and document the expectation that all migrations must be safe to re-run.
Configuration and Tuning Knobs
lock_timeout should be set inside the revision for the transactional half. The concurrent build does not need it — it takes only weak locks — but op.add_column does, and a blocked ALTER queueing behind a long transaction is exactly the outage the concurrent build was meant to prevent:
op.execute("SET lock_timeout = '3s'")statement_timeout must be unset or generous for the build itself. A deployment pipeline that sets a global five-minute statement timeout will kill a forty-minute index build, leaving an invalid index and a failed deploy. Set it to zero explicitly in the revision if your environment imposes one:
op.execute("SET statement_timeout = 0")maintenance_work_mem speeds the build materially — a GiST build is sort-heavy, and raising this session setting to a gigabyte on a machine that can spare it can halve the wall-clock time.
Verification Steps
-- the index exists, is valid, and is the right access method
SELECT ic.relname, am.amname, x.indisvalid, x.indisready
FROM pg_index x
JOIN pg_class ic ON ic.oid = x.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE ic.relname = 'idx_listings_geom';
-- no invalid indexes anywhere in the schema
SELECT indexrelid::regclass AS invalid_index
FROM pg_index WHERE NOT indisvalid;The second query belongs in monitoring, not just in a migration checklist. An invalid index costs write throughput indefinitely and is invisible to every read path, so nothing else will ever surface it.
Splitting the revision when the build is long
For an index build measured in hours, keeping the build inside the deployment pipeline is the wrong shape regardless of how the transaction is handled. The pipeline blocks, its timeout eventually fires, and the half-built index is left behind.
The alternative is two revisions and an operational step between them. The first revision adds the column and any constraints, and is fast. The index build runs as a standalone task — a one-off job, a maintenance script, a database administrator — with its own monitoring and no deadline. The second revision then asserts the index exists and fails the deploy if it does not:
def upgrade() -> None:
valid = op.get_bind().execute(sa.text(
"SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass('idx_listings_geom')"
)).scalar()
if not valid:
raise RuntimeError("idx_listings_geom is missing or invalid — "
"run the index build before deploying this version")That assertion is what keeps the two halves honest: the application version that depends on the index cannot ship until the index is genuinely there.
Gotchas Checklist
spatial_index=Falseon the column is not optional. GeoAlchemy2 creates a locking GiST index by default, and it runs before your concurrent build.DROP INDEX CONCURRENTLYalso cannot run in a transaction. The downgrade needs the sameCOMMITtreatment as the upgrade.IF NOT EXISTSdoes not detect an invalid index. An invalid index exists, so the create is skipped and the migration succeeds with a useless index. Dropping first is what makes the sequence correct.- Offline mode cannot do any of this.
alembic upgrade head --sqlproduces a script for a DBA to run, and the autocommit trick does not translate. Generate the concurrent statements as separate operational steps in that workflow. - Long builds outlive short deploy timeouts. If the pipeline caps a migration at ten minutes, the index build belongs outside the pipeline, with a lightweight revision that only asserts the index exists.
Related Topics
- Alembic for Spatial Schemas — parent topic: configuring Alembic for PostGIS
- Autogenerating Spatial Migrations With Alembic — the filters that stop destructive diffs
- CREATE INDEX CONCURRENTLY on Large Spatial Tables — what the database is doing during the build
- Concurrent Index Builds — the operational context around this migration