← All posts

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. It’s SQLAlchemy’s companion migration tool, built by the same author, deeply integrated with the ORM.

The reason we’re writing about this decision is that the obvious choice isn’t always the right one, and we wanted to make sure. The alternative we evaluated seriously was Liquibase — a mature, Java-ecosystem migration tool with strong enterprise adoption and a declarative changelog format.

The Case for Liquibase

Liquibase has real advantages:

Database-agnostic changelogs. You write migration changesets in XML, YAML, JSON, or SQL, and Liquibase generates the correct DDL for PostgreSQL, MySQL, Oracle, SQL Server, and others. If you’re running the same application against multiple database engines, this is genuinely valuable.

Rich audit trail. Liquibase records author, timestamp, checksum, and execution status for every changeset in a DATABASECHANGELOG table. You can see exactly what was applied, when, and by whom. This matters for compliance-heavy environments.

ORM independence. Liquibase changelogs are separate from application code. You can use Liquibase with any ORM or no ORM at all. The migration layer and the application layer are decoupled.

These are real strengths. For a Java application running against multiple databases with enterprise compliance requirements, Liquibase is a strong choice.

Why It Didn’t Fit

None of Liquibase’s strengths aligned with smplkit’s actual needs:

We only run PostgreSQL. ADR-009 standardizes on PostgreSQL across all environments — AWS RDS today, managed PostgreSQL on Azure or GCP if needed. We already use PostgreSQL-specific features: UUID types, timestamptz, JSONB columns with GIN indexes. Cross-database abstraction would go unused, and using it would mean avoiding the PostgreSQL-specific features that make our schema cleaner.

SQLAlchemy models are the source of truth. Our schema is defined in SQLAlchemy model classes. Alembic’s autogenerate feature diffs these models against the live database and produces migration scripts automatically. Liquibase changelogs are ORM-agnostic — which means the schema would be defined in two places (SQLAlchemy models for the application and Liquibase changelogs for the database), creating a synchronization burden with no offsetting benefit.

Data migrations need Python. Several of our migrations involve data transformations alongside schema changes — for example, the migration that replaced cognito_sub with auth_provider and auth_subject columns required conditional data transformation. In Alembic, this is straightforward Python with full access to SQLAlchemy’s query builder. In Liquibase, complex data migrations require <sql> blocks or <customChange> Java classes — less natural for a Python team.

Alembic’s audit trail is sufficient. Alembic tracks the current head revision in an alembic_version table and relies on git history for the full audit trail. For a small team where git is the system of record, this is enough. If compliance requirements later demand database-level audit trails, we can revisit.

The Integration Advantage

Alembic isn’t just compatible with SQLAlchemy — it’s built on it. When you add a column to a SQLAlchemy model, alembic revision --autogenerate detects the change and writes the migration draft. Review it, adjust if needed, commit it. The model and the migration stay in sync because they share a source of truth.

This eliminates an entire class of bugs: the ones where application code expects a column that doesn’t exist in the database, or where the database has a column that the application doesn’t know about. With Alembic, the model IS the schema definition, and the migration IS the change script.

FastAPI’s documentation assumes SQLAlchemy. The broader Python ecosystem (testing tools, fixtures, factories) assumes SQLAlchemy. Choosing a different migration tool would mean working against the grain of the stack for no benefit.

Migrate on Startup

Our backend Dockerfile runs alembic upgrade head before starting the application. This means every deployment automatically applies pending migrations. No separate migration step, no deploy scripts, no manual intervention.

The one wrinkle is concurrency: when multiple ECS task replicas start simultaneously, they all try to run migrations at the same time. We handle this with a PostgreSQL advisory lock in the Alembic environment — the first replica acquires the lock, runs the migration, and releases it. The others wait briefly, find no pending migrations, 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 Decision

Alembic is smplkit’s migration tool. SQLAlchemy models are the authoritative schema definition. Autogenerate produces migration drafts. Migrations run on startup with advisory lock protection.

Liquibase is a capable tool that solves problems we don’t have. Choosing it would have introduced a second schema definition layer alongside SQLAlchemy without corresponding benefit. Sometimes the right decision is confirming that the obvious choice is, in fact, the right one.

smplkit uses SQLAlchemy 2.0 and Alembic for all database operations. See our schema conventions in the next post.