Why Alembic over Liquibase for a Python FastAPI stack
If you’re building a Python backend with SQLAlchemy and you need database migrations, Alembic is the obvious choice — SQLAlchemy’s companion migration tool, built by the same author, deeply integrated with the ORM. We evaluated Liquibase seriously anyway, because the obvious choice isn’t always the right one, and we wanted to have actually checked.
The conclusion is at the bottom, but the most useful thing in this post is the part about what happens when three copies of your service all wake up and try to migrate the same database. Start there.
Migrate on startup, and the race that comes with it
Our backend Dockerfile runs alembic upgrade head before starting the application. Every deployment applies pending migrations automatically — no separate migration step, no deploy scripts, no human in the loop.
The wrinkle is concurrency. When ECS deploys, multiple task replicas start simultaneously, which means multiple containers all reach alembic upgrade head at the same moment, against the same database, each fully prepared to apply the same DDL. We handle this with a PostgreSQL advisory lock in the Alembic environment: the first replica acquires the lock and runs the migration; the others wait briefly, find no pending migrations when the lock frees, and proceed to start the application.
This is a solved problem, but it’s the kind of thing you discover in production if you don’t think about it during setup.
The case for Liquibase
Liquibase’s strengths are real. Database-agnostic changelogs — write changesets in XML, YAML, JSON, or SQL and Liquibase generates correct DDL for PostgreSQL, MySQL, Oracle, SQL Server, and others, which is genuinely valuable if you ship one application against several engines. A rich audit trail — author, timestamp, checksum, and execution status for every changeset in a DATABASECHANGELOG table, which compliance-heavy environments care about. And ORM independence — the migration layer is fully decoupled from application code, usable with any ORM or none.
For a Java application running against multiple databases under enterprise compliance requirements, Liquibase is a strong choice.
Why none of that fit us
We only run PostgreSQL. Our standard is PostgreSQL everywhere — AWS RDS today, managed PostgreSQL elsewhere if that ever changes — and we lean on PostgreSQL-specific features: UUID types, timestamptz, JSONB columns with GIN indexes. Cross-database abstraction would go unused, and honoring it would mean giving up the features that make our schema cleaner.
SQLAlchemy models are the source of truth. Our schema is defined in SQLAlchemy model classes, and Alembic’s autogenerate diffs those models against the live database and writes the migration draft. Liquibase changelogs are ORM-agnostic, which in our stack means the schema would be defined twice — models for the application, changelogs for the database — with a synchronization burden and no offsetting benefit.
Data migrations need Python. Several of our migrations transform data alongside the schema change — the migration that replaced cognito_sub with auth_provider and auth_subject columns required conditional data transformation. In Alembic that’s ordinary Python with full access to SQLAlchemy’s query builder. In Liquibase, complex data migrations mean <sql> blocks or <customChange> Java classes, which is not where a Python team wants to spend an afternoon.
Alembic’s audit trail is enough. Alembic tracks the head revision in an alembic_version table and leaves the full history to git. For a small team where git is already the system of record, that’s sufficient; if compliance requirements ever demand database-level audit trails, we’ll revisit.
The integration payoff
Alembic is built on SQLAlchemy, not merely compatible with it. Add a column to a model, run alembic revision --autogenerate, review the draft, commit. Model and migration stay in sync because they share one source of truth, which eliminates an entire class of bugs: application code expecting a column the database doesn’t have, or the database carrying a column the application has never heard of.
There’s also the grain of the ecosystem. FastAPI’s documentation assumes SQLAlchemy; the Python testing ecosystem — fixtures, factories — assumes SQLAlchemy. A different migration tool would mean working against that grain for no benefit.
The decision
Alembic is smplkit’s migration tool. Models are the authoritative schema, autogenerate writes the drafts, migrations run on startup behind an advisory lock.
Liquibase is a capable tool that solves problems we don’t have. Choosing it would have added a second schema definition layer alongside SQLAlchemy with no offsetting benefit. Sometimes the right decision is confirming that the obvious choice is, in fact, the right one.