Database Migrations with Alembic

RoadmapsPython Backend

Scenario

You add an 'age' column to your User model and deploy to production. The app immediately crashes with `column 'age' does not exist`. You forgot that changing Python code doesn't magically alter the Postgres tables.

Mental model

Migrations are 'git for your database schema'. Just as git tracks changes to your code over time, migrations track changes to your tables over time, ensuring every environment (local, staging, prod) has the exact same structure.

Alembic provides a linear history of Python scripts, where each script defines how to upgrade the database schema (e.g., add a column) and how to downgrade it (e.g., drop the column).

Deep dive

When you first start a project, you can use SQLModel.metadata.create_all() to build tables. But in production, you can't just drop and recreate tables when a schema changes—you would lose all user data.

create_all() is for prototypes; migrations are for production.

Instead, we use Alembic. Alembic generates migration scripts. A migration script contains two functions: upgrade() (how to apply the change, like adding a column) and downgrade() (how to reverse it, like dropping the column).

A migration script contains upgrade and downgrade instructions.

Alembic can 'autogenerate' these scripts by comparing your Python models against the actual database schema. It sees that User.age exists in Python but not in Postgres, and writes the ALTER TABLE command for you.

Autogenerate compares your code to the DB to write the migration.

However, autogenerate is not magic. It often misinterprets renames. If you rename first_name to given_name, Alembic usually thinks you dropped first_name (deleting the data!) and added a new, empty given_name column. You MUST read the generated script before applying it.

Always manually review autogenerated scripts to prevent data loss.

Code examples

An Alembic migration script

def upgrade():
    # Alembic autogenerated this:
    op.add_column("user", sa.Column("age", sa.Integer(), nullable=True))

def downgrade():
    # Reverses the upgrade
    op.drop_column("user", "age")

This script is checked into version control. When deployed, Alembic runs upgrade() to safely alter the production table without destroying existing rows.

Fixing a renamed column in Alembic

def upgrade():
    # BAD: Autogenerate did this (data loss!)
    # op.drop_column('user', 'first_name')
    # op.add_column('user', sa.Column('given_name', sa.String()))
    
    # GOOD: You manually edit it to rename instead
    op.alter_column('user', 'first_name', new_column_name='given_name')

Never blindly trust `alembic revision --autogenerate`. Always open the file and verify it didn't choose a destructive DROP/ADD when you meant RENAME.

Key points

Common mistakes

Recall questions

Questions & answers

A developer renamed the `billing_address` field to `invoice_address` in the SQLAlchemy model, ran Alembic autogenerate, and pushed. In production, all users lost their address data. What happened?

Alembic's autogenerate feature cannot reliably detect renames. It saw that billing_address disappeared (so it generated a DROP statement) and invoice_address appeared (so it generated an ADD statement). The developer failed to read the generated script and manually change it to an ALTER COLUMN ... RENAME statement.

Why do we check migration scripts into version control instead of just running a SQL script on the prod DB directly?

Migrations ensure that the schema evolves deterministically across all environments. Local, staging, and production databases all run the exact same sequence of changes, preventing schema drift.

Continue learning

Previous: The N+1 Queries Problem

Next: Transactions and Atomicity

Return to Python Backend Roadmap