DBLift vs Alembic: pick by how you write schema changes

Looking for an Alembic alternative? Alembic diffs your models into Python revisions; DBLift runs the SQL you wrote, in version order. Where each one fits, with the two-heads merge case worked through.

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

AlembicDBLift
Unit of changePython revision with upgrade() and downgrade()SQL file, V-prefixed, with an optional matching U file
Where it comes fromAutogenerate against your models, or written by handWritten by hand. DBLift never generates DDL from models
OrderingA revision graph: each file names its parentThe version number in the filename
What the reviewer readsPython op calls; the SQL is rendered by the dialect at run timeThe SQL itself
History tablealembic_version, one row per headdblift_schema_history, one row per applied file, with its checksum
Configurationalembic.ini plus env.pyOne environment variable or one YAML file
EnginesAnything SQLAlchemy supports, with dialect-neutral operationsPostgreSQL, 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.

DetectedOptionalNot detected
Table add and dropColumn type changes (on by default since 1.12)Table renames: reported as a drop and an add
Column add and dropServer default changes (off by default)Column renames: same, a drop and an add
Nullable changesNamed CHECK constraints (off by default)Anonymous constraints
Named indexes and unique constraintsFree-standing primary key and exclusion constraints
Foreign key changesSequences

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_guard raises 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-dblift plugin. Its dblift_migrated_db fixture 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_object hook in env.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.

DBLift is information technology / developer tools software. Contact: contact@dblift.com.