Alembic and PostGIS work together well once three specific misunderstandings are cleared up, and badly until then. Autogenerate proposes dropping spatial_ref_sys. It proposes dropping every GiST index GeoAlchemy2 created. And the transaction it wraps around each revision makes the one statement you most want in a spatial migration — CREATE INDEX CONCURRENTLY — impossible. None of these is subtle once seen; all of them are baffling the first time. This page, part of the SQLAlchemy and GeoAlchemy integration workflows, fixes each in turn and then covers what a good spatial migration looks like once the tooling stops arguing.
Prerequisites and Infrastructure Validation
The versions matter here more than usual, because GeoAlchemy2’s Alembic integration has changed materially:
python -c "import alembic, geoalchemy2, sqlalchemy; \
print(alembic.__version__, geoalchemy2.__version__, sqlalchemy.__version__)"
# 1.13.2 0.15.2 2.0.31GeoAlchemy2 0.12 and later ship an alembic_helpers module that handles the geometry column type in autogenerate output; earlier versions require rendering the type by hand in every migration. SQLAlchemy 2.0 changes the declarative style but not the migration mechanics.
Confirm the extension is present and note which objects it owns, since those are exactly what needs filtering:
SELECT c.relname, c.relkind
FROM pg_class c
JOIN pg_depend d ON d.objid = c.oid
JOIN pg_extension e ON e.oid = d.refobjid
WHERE e.extname = 'postgis'
ORDER BY c.relkind, c.relname;Core Execution Workflow
Step 1 — Filter what autogenerate is allowed to see
Everything begins with include_object in env.py. Without it, the first generated revision is destructive:
# alembic/env.py
from alembic import context
from sqlalchemy import engine_from_config, pool
from myapp.models import Base
target_metadata = Base.metadata
# Objects PostGIS owns, which the model will never declare.
POSTGIS_TABLES = {"spatial_ref_sys", "geometry_columns", "geography_columns",
"raster_columns", "raster_overviews", "topology", "layer"}
def include_object(obj, name, type_, reflected, compare_to):
"""Keep autogenerate away from anything the PostGIS extension owns."""
if type_ == "table" and name in POSTGIS_TABLES:
return False
# GeoAlchemy2 creates the GiST index implicitly; it is not in the metadata,
# so a reflected spatial index would otherwise be proposed for deletion.
if type_ == "index" and reflected and compare_to is None:
if name.startswith("idx_") and name.endswith("_geom"):
return False
return True
def run_migrations_online() -> None:
connectable = engine_from_config(
context.config.get_section(context.config.config_ini_section),
prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()The index rule above is deliberately conservative — it matches only the naming convention GeoAlchemy2 uses. A broader rule that ignores all reflected indexes would also hide genuine drift, which is worse than the problem it solves.
Step 2 — Make the generated migration importable
Autogenerate emits geoalchemy2.types.Geometry(...) into the revision, and the revision template does not import it, so the migration fails at import time with a NameError. Fix it once in the template rather than in every file:
# alembic/script.py.mako — add alongside the existing imports
import sqlalchemy as sa
import geoalchemy2 # noqa: F401 (needed by autogenerated column types)
${imports if imports else ""}Step 3 — Read the diff before running it
The middle band is where most of the work sits. Autogenerate emits op.create_index(..., postgresql_using="gist"), which is correct SQL and wrong operationally on a large table: it locks. Replacing it with a concurrent build is a two-line change and the subject of its own guide.
Step 4 — Write a downgrade that would actually work
Alembic generates a downgrade automatically and it is right often enough that people stop reading it. For spatial migrations it is frequently wrong in one specific way: the order of operations. Dropping a geometry column before dropping its index works; dropping a constraint after dropping the column it references does not. The rule is that a downgrade is the reverse sequence, not merely the inverse operations:
def upgrade() -> None:
op.add_column("listings",
sa.Column("geom", Geometry("POINT", srid=4326), nullable=True))
op.create_index("idx_listings_geom", "listings", ["geom"],
postgresql_using="gist")
op.create_check_constraint("listings_geom_srid", "listings",
"geom IS NULL OR ST_SRID(geom) = 4326")
def downgrade() -> None:
# exact reverse order — constraint, then index, then column
op.drop_constraint("listings_geom_srid", "listings", type_="check")
op.drop_index("idx_listings_geom", table_name="listings")
op.drop_column("listings", "geom")Performance Considerations
Migrations are usually judged on correctness, but on a spatial schema they have a performance dimension that shows up as downtime. Three operations dominate:
Index creation is the big one. A GiST build on tens of millions of geometries is tens of minutes, and inside a transaction-wrapped Alembic revision it holds a lock for all of it. This is the single most common cause of a “quick migration” turning into an incident.
Constraint validation scans the table. ST_IsValid in a check constraint makes that scan expensive — it is an overlay-class operation per row. Adding the constraint NOT VALID and validating separately splits one long lock into a short one plus a long weak one.
Column type changes rewrite the heap. ALTER COLUMN geom TYPE geometry(Point, 3857) USING ST_Transform(...) looks like a schema tweak and is a full table rewrite with a full index rebuild, under an exclusive lock throughout. The add-a-column-and-backfill pattern exists precisely to avoid it.
None of these is a reason to avoid Alembic. They are reasons to treat a spatial revision as a deployment with an operational plan rather than as a file that runs on startup.
Structuring Spatial Migrations
Beyond the tooling, spatial revisions benefit from a few structural habits that have nothing to do with Alembic itself and everything to do with what geometry changes cost.
One concern per revision. A revision that adds a column, backfills it, indexes it and constrains it is four operations with four different lock profiles and four different durations. Split into four revisions, each can be deployed, verified and if necessary rolled back on its own — and the long one can be scheduled separately from the fast ones.
Name the revision after the change, not the ticket. add_geom_to_listings tells an operator at 2 a.m. what the migration does; JIRA_4172 does not. The ticket reference belongs in the docstring where it is useful and not in the filename where it is noise.
Put the operational facts in the docstring. Expected duration, lock strength, reversibility, and whether the revision is safe to run during traffic. Three lines that take a minute to write and save an argument during every deploy:
"""add coverage geometry to districts
Revision ID: 4c9d20e1f8a6
Lock: brief ACCESS EXCLUSIVE for the ADD COLUMN; index built CONCURRENTLY.
Duration: ~3 s plus a 40-minute background index build.
Reversible: yes, no data loss (the column is populated by a later revision).
"""Keep data migrations out of schema revisions. A backfill that takes an hour does not belong in the deployment path. The schema revision creates the column; an operational job — resumable, batched, monitored, following the backfill patterns — populates it. The two are then independently retryable, which is the property that matters when either one fails.
Test against a database with rows. An empty schema hides every interesting failure: constraint violations, ordering bugs in downgrades, NOT NULL additions that cannot succeed. A CI fixture that loads a few thousand representative geometries — including an invalid one, an empty one and one in the wrong SRID — catches migration bugs that no amount of code review will.
Python Integration Patterns
Two helpers earn their place in almost every spatial Alembic project.
The first wraps the lock-timeout-and-retry pattern so individual revisions do not each reimplement it:
# migrations/helpers.py
import time
from alembic import op
import sqlalchemy as sa
from psycopg import errors
def ddl_with_retry(statement: str, *, attempts: int = 10,
lock_timeout: str = "3s") -> None:
"""Run a DDL statement, backing off while another transaction holds the table."""
for attempt in range(1, attempts + 1):
try:
op.execute(sa.text(f"SET LOCAL lock_timeout = '{lock_timeout}'"))
op.execute(sa.text(statement))
return
except errors.LockNotAvailable:
wait = min(2 ** attempt, 30)
print(f"lock busy; retrying in {wait}s ({attempt}/{attempts})")
time.sleep(wait)
raise RuntimeError(f"could not acquire the lock for: {statement[:60]}…")The second asserts a precondition, which is how a revision refuses to run against a database that is not in the state it expects:
def require(condition_sql: str, message: str) -> None:
"""Fail the migration early and clearly if a precondition does not hold."""
if not op.get_bind().execute(sa.text(condition_sql)).scalar():
raise RuntimeError(f"precondition failed: {message}")
# in a revision
require("SELECT to_regclass('public.listings') IS NOT NULL",
"the listings table must exist before adding its geometry column")
require("SELECT count(*) = 0 FROM listings WHERE NOT ST_IsValid(geom)",
"repair invalid geometry before adding the validity constraint")A precondition that fails produces a clear message and a database unchanged. Without it, the same situation produces a partially applied migration and a confusing error three statements later.
Multiple environments, one migration history
Spatial projects tend to accumulate databases: a developer copy, a CI container, staging with a sanitised extract, production. Alembic keeps one linear history across all of them, and the failure mode is a revision that works on the small ones and not on the large one.
Two habits prevent most of that divergence, and both are cheap to adopt. Seed the CI database with data shaped like production — including the awkward geometries, not just valid ones — so constraint and validation failures surface in the pipeline. And record the expected duration in the revision docstring based on the production row count rather than the developer copy, so nobody is surprised by an index build that took two seconds locally and forty minutes for real.
Common Failure Modes and Fixes
NameError: name 'geoalchemy2' is not defined — the template import from step 2 is missing. It recurs whenever the template is regenerated by alembic init.
Autogenerate proposes dropping spatial_ref_sys — the include_object filter is missing or env.py was replaced. Running that revision will break every spatial function that resolves an SRID.
Every autogenerate run produces a no-op index drop and recreate — the index name in the database differs from the one the model implies, usually because GeoAlchemy2’s naming changed between versions. Pin the name explicitly in the column definition and the churn stops.
CREATE INDEX CONCURRENTLY cannot run inside a transaction block — expected; see the dedicated guide for the two ways out.
A downgrade fails on a foreign key — the reverse-order rule was not followed. Test downgrades in CI against a schema with data, not an empty one; an empty schema hides ordering bugs completely.
Working With an Existing Database
Not every project starts with an empty database. Adopting Alembic on a PostGIS schema that already exists needs one extra step, and getting it wrong is the most common way to lose a production schema.
The step is stamping. alembic stamp head records the current revision as applied without running anything, which tells Alembic “the database already looks like this”. The sequence for an existing database is therefore:
# 1. generate a baseline revision describing the schema as it stands
alembic revision --autogenerate -m "baseline"
# 2. READ IT. With the filters configured, it should contain only your tables.
# Without them, it contains drop statements for spatial_ref_sys and every index.
# 3. mark it applied without executing it
alembic stamp headStep two is not a formality. Running an unreviewed baseline against the database it was generated from is usually harmless, and running it against a sibling database — staging, a colleague’s copy — is where the drops actually execute. The filters from the autogenerate guide must be in place before the baseline is generated, not after.
After stamping, the useful check is that alembic check reports nothing: the model, the database and the migration history now agree, which is the state every subsequent migration starts from.
Verification
The most valuable Alembic test is not that a migration runs, but that it is reversible and that autogenerate is quiet afterwards:
# apply, reverse, re-apply — all three must succeed
alembic upgrade head && alembic downgrade -1 && alembic upgrade head
# and the model must now match the database exactly
alembic check # exits non-zero if autogenerate would produce anythingalembic check in CI is the single highest-value guard for a spatial schema. It catches the case where somebody added a GiST index by hand in psql and never told the model — which is how a staging database and a production one start diverging.
Related Topics
- SQLAlchemy & GeoAlchemy Integration Workflows — parent section: models, sessions and types
- Autogenerating Spatial Migrations With Alembic — the filters and hooks in full
- Running CREATE INDEX CONCURRENTLY From Alembic — escaping the transaction wrapper
- Writing Reversible Geometry Migrations — downgrades that survive contact with data
- Spatial Schema Migrations & Evolution — the operational side of the same changes