Home/Learn/FastAPI/Alembic Migrations — Evolving the Schema Safely

Alembic Migrations — Evolving the Schema Safely

Intermediate
Databases

Alembic versions your schema like git versions code: autogenerate diffs models against the DB, you review the script, alembic upgrade head applies it — and create_all never touches production.

Overview

Base.metadata.create_all() creates missing tables but will never alter an existing one — add a column to a model and production silently doesn't have it. Alembic (SQLAlchemy's migration tool) fixes this with versioned migration scripts: each has upgrade() and downgrade(), forming a chain the alembic_version table tracks per database. The daily loop is change model → alembic revision --autogenerate → review the script → alembic upgrade head. Review is not optional: autogenerate sees add/drop but misreads renames as drop+add (data loss) and misses server defaults for NOT NULL backfills. In deployment, migrations run as a release step before new code starts, and risky changes ship in two deploys — expand, migrate data, then contract.

Setup and the Daily Loop

Point env.py at your models' metadata and your real database URL; from then on it is revision → review → upgrade. The migration scripts are code — committed, reviewed in PRs, never edited after they've run anywhere shared.

model change → autogenerate → review → upgrade head
pip install alembic
alembic init alembic                     # creates alembic/ + alembic.ini

# ── alembic/env.py — the two lines that matter ──
from app.core.db import Base
from app.models import student, offer    # import ALL model modules (registers tables)
target_metadata = Base.metadata
# and set the URL from settings, not alembic.ini:
# config.set_main_option("sqlalchemy.url", get_settings().database_url)

# ── the daily loop ──
# 1. Edit the model: add  backlogs: Mapped[int] = mapped_column(default=0)
# 2. Generate:
alembic revision --autogenerate -m "add backlogs to students"
# 3. REVIEW alembic/versions/9f2c_add_backlogs_to_students.py:
def upgrade():
    op.add_column("students",
        sa.Column("backlogs", sa.Integer(), nullable=False,
                  server_default="0"))   # ← you often ADD this by hand:
                                         #    NOT NULL on a full table needs a default
def downgrade():
    op.drop_column("students", "backlogs")
# 4. Apply:
alembic upgrade head

# Everyday commands:
alembic current            # what version is this DB on?
alembic history            # the chain of migrations
alembic downgrade -1       # step back one (dev only, usually)

What Autogenerate Gets Wrong + Production Discipline

Autogenerate is a diff, not a mind reader. Renames, data backfills, and some type changes need hand-written operations — and production migrations have their own rules of engagement.

Review autogen like a hostile PR; expand → migrate → contract
# 1. RENAME: you renamed cgpa → gpa. Autogenerate emits:
#      op.drop_column("students", "cgpa")     ← DATA GONE
#      op.add_column("students", sa.Column("gpa", sa.Float()))
#    Fix by hand:
def upgrade():
    op.alter_column("students", "cgpa", new_column_name="gpa")

# 2. DATA MIGRATION: new column derived from old data —
def upgrade():
    op.add_column("students", sa.Column("email_domain", sa.String(80)))
    op.execute(
        "UPDATE students SET email_domain = split_part(email, '@', 2)"
    )
    op.alter_column("students", "email_domain", nullable=False)

# 3. Production rules:
#   - migrations run BEFORE new code serves traffic:
#       fly.toml → [deploy] release_command = "alembic upgrade head"
#   - never edit a migration that has run on any shared DB — add a new one
#   - big/risky changes = expand → migrate → contract across TWO deploys:
#       deploy 1: add nullable column, write to both old+new
#       backfill: batched UPDATEs (not one giant locking UPDATE)
#       deploy 2: drop the old column once nothing reads it
#   - index on a busy Postgres table: CREATE INDEX CONCURRENTLY
#     (needs op.execute + autocommit block — plain create_index locks writes)

Key Points to Remember

  • 1create_all never ALTERs — schema changes go through Alembic, always
  • 2Loop: model change → revision --autogenerate → REVIEW → upgrade head
  • 3Autogen misreads renames as drop+add and forgets NOT NULL server defaults
  • 4Run migrations as a release step; risky changes = expand → migrate → contract

Interview Questions

Sign in to ask Aria
1

Why is Base.metadata.create_all() insufficient once you are in production?

MediumGroww
2

You renamed a column — what does autogenerate produce and why is it dangerous?

HardCRED
3

Add a NOT NULL column to a table with 50 million rows, zero downtime — your plan?

HardPhonePe

Ask Aria about Alembic Migrations — Evolving the Schema Safely

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…