Problem Statement
Run alembic revision --autogenerate against a fresh PostGIS database and the first revision proposes dropping spatial_ref_sys, geography_columns and every GiST index in the schema. Run it against a mature one and it proposes the same index drop-and-recreate on every invocation, forever. Both are configuration problems with specific fixes, and this page walks through each in the order you hit them while setting up Alembic for a spatial schema.
Why the Naive Approach Fails
Autogenerate compares two things: the SQLAlchemy metadata your models define, and the schema it reflects from the database. Anything present in the database and absent from the metadata is a candidate for deletion. That rule is exactly right for an ordinary schema and wrong for a spatial one, because PostGIS puts objects in your database that no model will ever declare.
The third is the dangerous one, because it works. Nothing errors, no test fails, and the application gets slower in a way nobody attributes to a migration.
Production-Ready Implementation
The include_object hook does the filtering, and being specific about why each rule exists keeps it maintainable:
# alembic/env.py
POSTGIS_OWNED = {
"spatial_ref_sys", "geometry_columns", "geography_columns",
"raster_columns", "raster_overviews", "topology", "layer",
}
def include_object(obj, name, type_, reflected, compare_to):
"""Decide whether autogenerate may consider an object.
Returning False means 'pretend this does not exist', which for a reflected
object means 'do not propose dropping it'.
"""
# 1. Objects the PostGIS extension owns.
if type_ == "table" and name in POSTGIS_OWNED:
return False
# 2. Tables in the topology or tiger schemas, if those extensions are installed.
if type_ == "table" and getattr(obj, "schema", None) in {"topology", "tiger"}:
return False
# 3. Reflected GiST indexes with no metadata counterpart: GeoAlchemy2 created
# them implicitly, so proposing a drop would silently de-index the table.
if type_ == "index" and reflected and compare_to is None:
if getattr(obj, "dialect_options", {}).get("postgresql", {}).get("using") == "gist":
return False
return TrueRule three uses the access method rather than a name pattern, which is more robust than matching idx_%_geom — it survives a renamed index and does not accidentally protect a B-tree that genuinely should be dropped.
Making the model declare what the database has
Filtering hides the asymmetry; declaring the index removes it. This is the better fix where you control the model:
from geoalchemy2 import Geometry
from sqlalchemy import Index
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Listing(Base):
__tablename__ = "listings"
id: Mapped[int] = mapped_column(primary_key=True)
# spatial_index=False: we declare the index explicitly below, so the
# metadata and the database agree and autogenerate has nothing to say.
geom: Mapped[str] = mapped_column(
Geometry("POINT", srid=4326, spatial_index=False), nullable=False)
__table_args__ = (
Index("idx_listings_geom", "geom", postgresql_using="gist"),
)With the index declared, autogenerate compares like with like: the index is in both the metadata and the database, so no diff is produced and no filter rule is needed for it. The filter stays for the PostGIS-owned tables, which will never be in the metadata.
Gating drift in CI
Once the diff is clean, keep it clean:
# .github/workflows/ci.yml (fragment)
- name: schema drift check
run: |
alembic upgrade head
alembic check # non-zero exit if autogenerate would produce a revisionalembic check is the payoff for all the configuration above. It turns “somebody added an index in psql and forgot the migration” from a mystery discovered months later into a failed pull request.
Configuration and Tuning Knobs
compare_type=True catches column type changes and is worth enabling once GeoAlchemy2’s comparator is in play. On older versions it produces a spurious alter for every geometry column on every run; if you see that, upgrade GeoAlchemy2 before reaching for a workaround.
compare_server_default=True is usually more noise than value on a spatial schema, because generated columns and PostGIS defaults render differently between the model and the reflection.
version_table_schema matters if your application uses a dedicated schema. Leaving Alembic’s version table in public alongside PostGIS objects works but muddles ownership; putting it in the application schema keeps a pg_dump --schema clean.
render_as_batch is a SQLite concern and should stay off for PostgreSQL — batch mode rewrites tables, which is catastrophic on a large spatial table.
Verification Steps
# 1. a fresh autogenerate against an up-to-date database must be empty
alembic revision --autogenerate -m "should be empty"
grep -c "op\." alembic/versions/*_should_be_empty.py # expect 0 in up/downgrade
rm alembic/versions/*_should_be_empty.py
# 2. and the built-in equivalent, suitable for CI
alembic check-- 3. the spatial objects that must survive every migration
SELECT to_regclass('spatial_ref_sys') IS NOT NULL AS srs_present,
(SELECT count(*) FROM pg_index x
JOIN pg_class ic ON ic.oid = x.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE am.amname = 'gist') AS gist_indexes;Reviewing a generated revision in practice
Reading a generated migration is a skill with a short checklist. Open the file and check four things before anything else.
Does it drop anything? Every op.drop_* in an autogenerated revision deserves an explanation, and on a spatial schema most of them are filter failures rather than intended changes.
Does it touch an index? If so, decide whether the operation should be concurrent, and rewrite it if the table is large.
Does it add a NOT NULL column or constraint? Autogenerate has no idea whether existing rows satisfy it, and the migration will fail on the first row that does not — or worse, succeed on staging and fail in production.
Does it change a type? A geometry type or SRID change is a table rewrite; treat the generated line as a note to write the real migration by hand.
Gotchas Checklist
include_objectis called for every object, including columns. ReturnTrueby default, or the filter will silently exclude things you never considered.- The
reflectedflag distinguishes the two sides. A rule that ignores the flag can hide a model-declared object as well as a database one, which makes autogenerate stop noticing real changes. - Autogenerate never sees data. It cannot know that a new
NOT NULLcolumn needs a backfill, and it will happily generate a migration that fails on the first existing row. - Generated revisions are drafts. Read every one; the ones that look boring are the ones that drop indexes.
alembic checkneeds an up-to-date database. In CI, runupgrade headagainst a fresh PostGIS container first, or the check reports drift that is really just an unapplied migration.
Related Topics
- Alembic for Spatial Schemas — parent topic: the whole Alembic and PostGIS setup
- Running CREATE INDEX CONCURRENTLY From Alembic — fixing the index operations autogenerate emits
- Writing Reversible Geometry Migrations — the downgrades autogenerate gets wrong
- Model Mapping with GeoAlchemy2 — declaring the columns and indexes this page compares against