Catch the migration you forgot to write. alembic-drift-check compares your
SQLAlchemy ORM models against a live database and fails the build when they
disagree — the classic "we changed a model but never generated (or never applied)
the Alembic migration" incident that only shows up in production.
It is not a reimplementation of Alembic's diff engine. It drives the exact same
machinery alembic revision --autogenerate uses
(compare_metadata),
so what it flags is precisely what a fresh autogenerated migration would contain.
A migration that never got written — or a migration that got merged but never
alembic upgraded against staging — leaves your database schema lagging behind
your models. Everything looks fine locally because SQLAlchemy emits DDL from the
models, but the deployed database is a different shape. alembic-drift-check
turns that silent divergence into a red build.
pip install "git+https://github.com/async-workflows/alembic-drift-check.git"
alembic-drift-check \
--metadata myapp.models:Base.metadata \
--database-url postgresql://user:pass@localhost/appIf the models and the database are in sync you get exit code 0. If drift
exists you get a report and exit code 1:
Schema drift detected: 2 differences.
+ add_table: posts
+ add_column: users.full_name
-- checked by alembic-drift-check
The tool is distributed from GitHub (it is not published on PyPI):
pip install "git+https://github.com/async-workflows/alembic-drift-check.git"To pin a specific revision, append @<tag-or-sha>:
pip install "git+https://github.com/async-workflows/alembic-drift-check.git@v0.1.0"Requires Python 3.10+, SQLAlchemy 2.0+, and Alembic 1.13+.
alembic-drift-check --metadata MODULE:ATTR [--database-url URL] [options]| Flag | Description |
|---|---|
--metadata MODULE:ATTR |
Required. Import spec for the target MetaData or a declarative Base. Both myapp.models:Base.metadata and myapp.models:target_metadata work — a Base is unwrapped to its .metadata. |
--database-url URL |
SQLAlchemy database URL to compare against. If omitted, falls back to $DATABASE_URL, then to sqlalchemy.url in alembic.ini. |
--alembic-ini PATH |
Path to alembic.ini used to resolve sqlalchemy.url (default: alembic.ini). |
--exclude GLOB |
Table-name glob to ignore. Repeatable, e.g. --exclude 'tmp_*' --exclude 'celery_*'. |
--ignore-removed |
Do not treat objects that exist in the database but not in the models as drift (useful during staged column/table removals). |
--generate-stub |
Render an Alembic revision-script stub with the op.* calls for the detected operations. |
--stub-out FILE |
Write the generated stub to FILE instead of stdout (implies --generate-stub). |
--json |
Emit machine-readable JSON instead of the human report. |
--version |
Print the version and exit. |
--generate-stub renders a ready-to-review upgrade() body from the drift so you
can drop it into a new revision:
alembic-drift-check \
--metadata myapp.models:Base.metadata \
--database-url sqlite:///app.db \
--generate-stubdef upgrade() -> None:
op.create_table('posts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.add_column('users', sa.Column('full_name', sa.String(length=120), nullable=True))Treat the stub as a starting point — always review autogenerated operations (see Further reading).
--json prints a stable structure suitable for downstream tooling:
{
"has_drift": true,
"entries": [
{ "kind": "add_table", "table": "posts", "detail": null, "is_removal": false },
{ "kind": "add_column", "table": "users", "detail": "full_name", "is_removal": false }
],
"ignored_removals": 0,
"stub": null,
"database_url": "sqlite:///app.db",
"attribution": "checked by alembic-drift-check"
}A composite action ships in this repository. Add a job that first brings up (or migrates) a database, then runs the check against it:
name: schema-drift
on: [pull_request]
jobs:
drift:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install app and apply migrations
run: |
pip install -e .
alembic upgrade head
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
- name: Check for schema drift
uses: async-workflows/alembic-drift-check@main
with:
metadata: myapp.models:Base.metadata
database-url: postgresql://postgres:postgres@localhost:5432/postgres
exclude: "tmp_* celery_*"The step migrates the database to head first, then asserts that the migrated
schema still matches the models — so a model change with no accompanying
migration fails the PR. Action inputs mirror the CLI flags
(metadata, database-url, exclude, ignore-removed, generate-stub, ref).
- Your target
MetaDatais imported from--metadata(aBaseis unwrapped toBase.metadata). - A SQLAlchemy engine connects to
--database-url; the driver decides whether a sync or async engine is used. - An
alembic.migration.MigrationContextis configured on that connection andcompare_metadatadiffs the reflected database schema against the models (withcompare_typeandcompare_server_defaultenabled). - Each diff is classified —
add_table,remove_table,add_column,remove_column,modify_type,add_index,add_constraint, and so on — and summarized.--generate-stubadditionally renders the operations to Python viarender_python_code.
Because it leans on Alembic's own comparison, the results track Alembic's known capabilities and blind spots exactly (see Limitations).
| Code | Meaning |
|---|---|
0 |
No drift — models and database are in sync. |
1 |
Drift detected. |
2 |
Usage or configuration error (bad --metadata spec, unresolvable URL, etc.). |
With --ignore-removed, differences that only remove objects from the database
no longer count toward the exit code.
Async database URLs are handled transparently. If the URL uses an async driver
(for example postgresql+asyncpg://… or sqlite+aiosqlite://…), the tool opens
an async engine and runs the comparison through
Connection.run_sync,
because Alembic's reflection and comparison code is synchronous. Sync URLs use a
plain synchronous engine. You do not need to configure anything — detection is
based on the URL's dialect.
- Drift detection is only as complete as Alembic autogenerate. Some changes are
not reliably detected across all backends — for example certain
CHECK-constraint edits, some server-default changes, and comment changes. See Alembic's documentation on what autogenerate detects. - Type comparison depends on the dialect; a
--generate-stubon SQLite may render slightly different types than the same models against PostgreSQL. - The tool compares against whatever schema the database currently has. Point it
at a database that has already had
alembic upgrade headapplied, so that a clean result means "migrations plus models agree." --excludefilters by table name; it does not currently filter by schema.
Background on the Alembic workflow this check plugs into:
Alembic async migrations and schema evolution,
autogenerating and reviewing migration scripts,
configuring Alembic with async SQLAlchemy engines,
setting up env.py for asyncpg,
and excluding tables and schemas from autogenerate.
MIT © 2026 async-workflows