Database Migrations with Alembic

Alembic is the database migration tool designed to work with SQLAlchemy. It tracks the history of schema changes as a series of versioned migration scripts โ€” each script has an upgrade() function (apply the change) and a downgrade() function (revert it). When you add a column, create a table, or change an index, you generate a migration script, commit it to version control, and apply it to every environment (development, staging, production) in sequence. Alembic solves the problem of keeping your database schema in sync with your SQLAlchemy models across a team and across deployments.

Setting Up Alembic

# Install
pip install alembic

# Initialise the migration environment (run once per project)
alembic init alembic

# Directory structure created:
# alembic/
#   env.py          -- configuration, connects to DB, imports models
#   script.py.mako  -- template for migration scripts
#   versions/       -- generated migration files
# alembic.ini       -- main configuration file
# alembic/env.py โ€” configure to use your SQLAlchemy models
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context

# Import your models so Alembic can detect schema changes
from app.database import Base   # your DeclarativeBase
from app.models import Post, User, Tag  # all your ORM models

# Alembic Config object
config = context.config

# Set target_metadata so autogenerate can detect model changes
target_metadata = Base.metadata

# Read DATABASE_URL from environment (not hardcoded!)
import os
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])

def run_migrations_offline() -> None:
    url = config.get_main_option("sqlalchemy.url")
    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
    with context.begin_transaction():
        context.run_migrations()

def run_migrations_online() -> None:
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    with connectable.connect() as connection:
        context.configure(connection=connection, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()

if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
Note: Alembic stores the current migration version in a table called alembic_version in your database. When you run alembic upgrade head, Alembic checks this table, determines which migrations have not yet been applied, and runs them in order. If the table does not exist (fresh database), Alembic creates it and applies all migrations from the beginning. This makes Alembic idempotent โ€” safe to run multiple times on the same database.
Tip: In the alembic.ini file, avoid hardcoding the database URL โ€” instead, configure it dynamically in env.py from an environment variable: config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"]). This keeps credentials out of version control and allows the same migration code to work across development, staging, and production with different database URLs.
Warning: Alembic’s autogenerate feature compares your SQLAlchemy models to the current database schema and generates a migration, but it has limitations: it cannot detect column renames (sees a drop + add instead), does not detect changes to stored procedures or triggers, and sometimes generates incorrect code for complex index or constraint changes. Always review the generated migration file before applying it โ€” never blindly run alembic upgrade head without reading the generated script.

Creating and Running Migrations

# Generate an autogenerated migration (compares models to current DB schema)
alembic revision --autogenerate -m "add_published_at_to_posts"
# Creates: alembic/versions/abc123_add_published_at_to_posts.py

# Generate an empty migration (for manual SQL operations)
alembic revision -m "add_full_text_search_index"

# Apply all pending migrations
alembic upgrade head

# Apply one migration at a time
alembic upgrade +1

# Roll back one migration
alembic downgrade -1

# Roll back to a specific revision
alembic downgrade abc123

# Roll back all migrations
alembic downgrade base

# Show current migration version
alembic current

# Show migration history
alembic history --verbose

Migration File Structure

# alembic/versions/abc123_add_published_at_to_posts.py
"""add published_at to posts

Revision ID: abc123def456
Revises:     xyz789abc123
Create Date: 2025-08-06 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa

revision = "abc123def456"
down_revision = "xyz789abc123"   # previous migration
branch_labels = None
depends_on = None

def upgrade() -> None:
    # โ”€โ”€ Safe: add nullable column โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    op.add_column("posts",
        sa.Column("published_at", sa.TIMESTAMP(timezone=True), nullable=True)
    )

    # โ”€โ”€ Create index concurrently (non-blocking on production) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    op.execute(
        "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_posts_published_at "
        "ON posts (published_at DESC) WHERE published_at IS NOT NULL"
    )

def downgrade() -> None:
    op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_posts_published_at")
    op.drop_column("posts", "published_at")

Production Migration Workflow

# โ”€โ”€ Safe production migration checklist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

# 1. Generate migration locally
alembic revision --autogenerate -m "add_search_vector_column"

# 2. Review the generated file
cat alembic/versions/*_add_search_vector_column.py

# 3. Test on development database
alembic upgrade head
# Verify: run your test suite, check endpoints work

# 4. Test on staging
DATABASE_URL=postgresql://...staging... alembic upgrade head

# 5. Apply to production (during low-traffic window for risky changes)
DATABASE_URL=postgresql://...prod... alembic upgrade head

# 6. If something goes wrong: roll back
DATABASE_URL=postgresql://...prod... alembic downgrade -1

# โ”€โ”€ Multiple heads (branched migrations) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
alembic heads     # list all head revisions (should be 1 in simple projects)
alembic merge heads   # merge two branches into one

Common Mistakes

Mistake 1 โ€” Hardcoding DATABASE_URL in alembic.ini

โŒ Wrong โ€” credentials in version control:

# alembic.ini
sqlalchemy.url = postgresql://admin:s3cr3t@prod.server/mydb   # exposed!

โœ… Correct โ€” read from environment in env.py:

config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])   # โœ“

Mistake 2 โ€” Not reviewing autogenerated migrations

โŒ Wrong โ€” running autogenerated migration without checking:

alembic revision --autogenerate -m "changes" && alembic upgrade head
# Autogenerate may generate: DROP TABLE users (if model was renamed!)
# Always check the script first

โœ… Correct โ€” review before applying:

alembic revision --autogenerate -m "changes"
cat alembic/versions/*_changes.py   # โœ“ read it first!
alembic upgrade head

Mistake 3 โ€” Using blocking CREATE INDEX in production migration

โŒ Wrong โ€” locks table during index build on production:

op.create_index("idx_posts_status", "posts", ["status"])
# Blocks all reads and writes during index build!

โœ… Correct โ€” use CONCURRENTLY via op.execute():

op.execute("CREATE INDEX CONCURRENTLY idx_posts_status ON posts(status)")   # โœ“

Quick Reference

Task Command
Init Alembic alembic init alembic
Autogenerate migration alembic revision --autogenerate -m "message"
Empty migration alembic revision -m "message"
Apply all alembic upgrade head
Apply one alembic upgrade +1
Roll back one alembic downgrade -1
Current version alembic current
Migration history alembic history --verbose

🧠 Test Yourself

You add a new column is_featured BOOLEAN NOT NULL DEFAULT FALSE to your SQLAlchemy Post model. What steps do you take to apply this change to your production database?