Alembic and DBLift both version a database from Python, and both keep a history table. Every other difference follows from one choice: what a migration is. In Alembic it is a Python revision script, usually generated by diffing your SQLAlchemy models against the database. In DBLift it is a SQL file you wrote, named with a version, applied in order.
If your schema lives in SQLAlchemy models and autogenerate produces revisions you rarely edit, Alembic is the right tool, and this post will not try to talk you out of it. If you write the SQL yourself, or you keep rewriting what autogenerate produced, read on.
The shape of each tool
| Alembic | DBLift | |
|---|---|---|
| Unit of change | Python revision with upgrade() and downgrade() | SQL file, V-prefixed, with an optional matching U file |
| Where it comes from | Autogenerate against your models, or written by hand | Written by hand. DBLift never generates DDL from models |
| Ordering | A revision graph: each file names its parent | The version number in the filename |
| What the reviewer reads | Python op calls; the SQL is rendered by the dialect at run time | The SQL itself |
| History table | alembic_version, one row per head | dblift_schema_history, one row per applied file, with its checksum |
| Configuration | alembic.ini plus env.py | One environment variable or one YAML file |
| Engines | Anything SQLAlchemy supports, with dialect-neutral operations | PostgreSQL, MySQL, SQL Server, Oracle, SQLite, DuckDB, DB2; Cosmos DB and MongoDB through Python migrations |
Two rows in that table decide most choices. Alembic's operations are dialect-neutral, so one revision can target several engines. DBLift runs your SQL as written, so a file that uses PostgreSQL syntax is a PostgreSQL file. If you ship one codebase against several databases, that row alone may settle it in Alembic's favour.
What autogenerate actually sees
Alembic's own documentation is precise about this, and it is worth reading the list before betting a workflow on it.
| Detected | Optional | Not detected |
|---|---|---|
| Table add and drop | Column type changes (on by default since 1.12) | Table renames: reported as a drop and an add |
| Column add and drop | Server default changes (off by default) | Column renames: same, a drop and an add |
| Nullable changes | Named CHECK constraints (off by default) | Anonymous constraints |
| Named indexes and unique constraints | Free-standing primary key and exclusion constraints | |
| Foreign key changes | Sequences |
The two rename rows are the ones that bite. A column rename comes out as a drop and an add, which is data loss if it reaches production unread. Views, functions, triggers, row-level security policies and grants are not models at all, so they never appear. Teams handle both cases the same way: they open the generated revision and write SQL into it by hand.
Autogenerate is a draft. Alembic's documentation says every generated revision must be reviewed and, where needed, corrected. Once most of your revisions carry hand-written SQL inside
op.execute(), you are already writing SQL migrations. The remaining question is where you would rather write them.
The merge problem, worked through
This is the case that comes up in every Alembic thread, and the one where the two tools differ most.
Alembic: two heads
Two developers branch from the same revision. Each runs autogenerate and commits a revision whose parent is the shared one. Both branches merge cleanly, because the files have different names. Then the deploy runs.
$ alembic upgrade head
FAILED: Multiple head revisions are present for given argument 'head'; please
specify a specific target revision, '<branchname>@head' to narrow to a specific
head, or 'heads' for all heads
The fix is a third revision whose only job is to have two parents.
$ alembic heads
$ alembic merge -m "merge orders and users branches" heads
$ alembic upgrade head
That works, and every Alembic team learns it. The costs are quieter: the two sibling revisions run in an order neither author chose, the merge file lives in the repository forever, and a fresh database walks the same diamond on every build. A team that branches often ends up with a graph that only alembic history can explain.
DBLift: a number, and a pending row
Same scenario. Versions 1 and 3 are applied. A branch that adds version 2 merges late. There is no graph to reconcile; the file simply exists now, and it is pending.
$ dblift info
│ Versioned │ 1 │ create_users │ SQL │ 2026-09-05 19:54:08 │ Success │
│ Versioned │ 3 │ add_orders │ SQL │ 2026-09-05 19:54:08 │ Success │
│ Versioned │ 2 │ users_created_at │ SQL │ │ Pending │
By default, DBLift applies it. That is the behaviour Flyway calls out-of-order, and it is the default here because late branches are normal in teams that review code.
$ dblift migrate --dry-run --show-sql
-- V2__users_created_at.sql
ALTER TABLE users ADD COLUMN created_at TEXT;
If you would rather be told, ask to be told. Strict mode refuses anything below the current version and names the file.
$ dblift migrate --strict
ERROR: Strict mode: out-of-order migration V2__users_created_at.sql
(version 2 <= current version 3). Renumber the script above 3 or run
without --strict to apply it anyway.
The other collision is two branches that both pick the same number. That never reaches the database.
$ dblift validate
ERROR: Version 2 is used by both migrations/V2__users_phone.sql and
migrations/V2__users_created_at.sql
The fix is a rename. No merge revision, no graph, and the conflict is visible in the pull request before anything runs.
Pick versions that do not collide. Sequential integers are fine for one person. For a team, use a timestamp as the version:
V20260905143000__add_phone.sql. Two branches opened minutes apart get different numbers, and the order still reads top to bottom in the directory listing.
Seeing the SQL before it runs
Alembic can print SQL instead of executing it. Offline mode renders every pending revision through the dialect, and it is a good habit before a production deploy.
$ alembic upgrade head --sql
The difference is what your reviewer looked at. In Alembic, the artefact in the pull request is Python, and the SQL is derived from it at run time by the dialect and the operation implementations. In DBLift, the artefact in the pull request is the SQL, and the dry run prints those same files. What was reviewed is what runs.
$ dblift migrate --dry-run --show-sql
For a database team that reviews DDL, or a DBA who will not sign off on Python, that is the whole argument. For a team where nobody reads DDL, it is not.
Rolling back
Alembic revisions carry a downgrade() function, and autogenerate fills it in for the structural cases. Rolling back one step is a single command, and for adds and drops it is usually correct.
$ alembic downgrade -1
DBLift does not guess. A rollback is a U file with the same version as the migration it reverses, and you write it. The info output shows which applied versions have one, so the gap is visible before you need it. Preview first, then roll back to a version.
$ dblift undo --dry-run --show-sql
$ dblift undo --target-version=1
Alembic's approach is more automatic. DBLift's forces a decision per migration, which is a cost on every file and a benefit on the day a generated downgrade would have dropped the wrong thing. Neither restores rows; a dropped column's data is gone either way.
SQLAlchemy, FastAPI, Django, tests
Both tools hand you a SQLAlchemy engine. Alembic wants it configured in env.py; DBLift takes the one your application already has.
from sqlalchemy import create_engine
from dblift.api import DBLiftClient
engine = create_engine("postgresql+psycopg://user:password@localhost/app")
with DBLiftClient.from_sqlalchemy(engine=engine, migrations_dir="migrations") as client:
client.validate()
client.migrate()
- FastAPI gets a read-only guard.
migration_guardraises at startup when migrations are pending; it never applies them. Apply stays a deploy step. - Django keeps its own ORM migrations. DBLift sits beside them with a separate history table for tables outside the ORM, views, procedures and reference data, and never touches
django_migrations. - Tests use the
pytest-dbliftplugin. Itsdblift_migrated_dbfixture applies your migrations before the test runs, and SQLite workers are isolated under xdist.
Alembic covers the same ground with env.py, Flask-Migrate, and a fixture you write around alembic.command.upgrade. None of it is hard. It is just yours to maintain.
Running both is possible, with one rule. Each tool keeps its own history table and neither knows about the other. Give every table one owner. Tables Alembic owns come from models; tables DBLift owns are excluded from autogenerate with the
include_objecthook inenv.py, or they will show up as drops on the next diff.
Pick by the question
- Your models are the source of truth and most changes are adds and drops: Alembic.
- One codebase runs against several engines and you want dialect-neutral operations: Alembic.
- The SQL is the source of truth, a DBA reviews it, or you have views, functions, procedures and policies to version: DBLift.
- Several branches touch migrations in the same week and merge revisions have become a ritual: DBLift.
- You migrated from Flyway, or you might: DBLift reads the same file names and imports the history.
Start with a SQLite file and one migration; it takes about five minutes.