Chapter 3: writing migrations by hand. The op API, common operations, data migrations, batch alters.

Empty migration

alembic revision -m "add admin role"

Creates a stub. Fill in upgrade() / downgrade().

op API basics

from alembic import op
import sqlalchemy as sa

def upgrade():
    op.create_table(
        "items",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("name", sa.String(120), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
    )
    op.create_index("ix_items_name", "items", ["name"])

def downgrade():
    op.drop_index("ix_items_name", "items")
    op.drop_table("items")

op.* provides all DDL operations.

Common operations

Add column

op.add_column("users", sa.Column("status", sa.String(20), nullable=False, server_default="active"))

Drop column

op.drop_column("users", "old_field")

Alter column

op.alter_column("users", "name",
    type_=sa.String(200),
    nullable=False,
    new_column_name="full_name",
)

Rename table

op.rename_table("old_name", "new_name")

Indexes

op.create_index("ix_users_email", "users", ["email"], unique=True)
op.drop_index("ix_users_email", "users")

Constraints

op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_check_constraint("ck_users_age_positive", "users", "age > 0")
op.create_foreign_key("fk_posts_user", "posts", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.drop_constraint("fk_posts_user", "posts", type_="foreignkey")

Raw SQL

op.execute("UPDATE users SET status = 'active' WHERE status IS NULL")
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")

For DB-specific or complex SQL.

Data migrations

def upgrade():
    op.add_column("users", sa.Column("status", sa.String(20), nullable=True))
    
    # Bind a connection for inserts/updates
    bind = op.get_bind()
    bind.execute(sa.text("UPDATE users SET status = 'active'"))
    
    op.alter_column("users", "status", nullable=False)

op.get_bind() returns the current connection. Use SQLAlchemy text or table objects.

For large tables: don’t load into Python. Use SQL:

op.execute("UPDATE users SET status = 'active'")

For really large tables: break into chunks (see Chapter 5).

Using SQLAlchemy in migrations

For data migration with structured access:

def upgrade():
    op.add_column("users", sa.Column("active", sa.Boolean, nullable=True))
    
    # Define a lightweight table for the migration
    users_table = sa.table(
        "users",
        sa.column("id", sa.Integer),
        sa.column("status", sa.String),
        sa.column("active", sa.Boolean),
    )
    
    bind = op.get_bind()
    bind.execute(
        users_table.update().values(active=(users_table.c.status == "active"))
    )
    
    op.alter_column("users", "active", nullable=False)

Don’t import your real model classes — they may have evolved. Define a frozen table inside the migration.

Conditional logic

def upgrade():
    bind = op.get_bind()
    inspector = sa.inspect(bind)
    
    if "users" in inspector.get_table_names():
        # ...
    
    cols = [c["name"] for c in inspector.get_columns("users")]
    if "old_field" in cols:
        op.drop_column("users", "old_field")

Inspect existing schema. Useful for migrations that have been partially applied.

batch_alter_table (SQLite)

SQLite doesn’t support most ALTER:

with op.batch_alter_table("users") as batch:
    batch.add_column(sa.Column("status", sa.String(20)))
    batch.alter_column("name", new_column_name="full_name")

Alembic emulates: creates new table, copies data, drops old, renames.

For Postgres / MySQL: not needed (native ALTER).

Postgres-specific

Concurrent indexes

def upgrade():
    op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")

def downgrade():
    op.execute("DROP INDEX CONCURRENTLY ix_users_email")

CONCURRENTLY can’t run in a transaction:

# alembic.ini or env.py
[alembic]
transaction_per_migration = false

Or per-migration setting:

def upgrade():
    op.execute("COMMIT")
    op.execute("CREATE INDEX CONCURRENTLY ...")
    op.execute("BEGIN")  # restart for the rest

Awkward. Better: transaction_per_migration = false for the whole project.

Extensions

def upgrade():
    op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")

ENUM

Adding a value:

op.execute("ALTER TYPE user_role ADD VALUE 'editor'")

Note: can’t be in a transaction in older PG. PG 12+: yes, but not undoable.

For schema-flexible: use VARCHAR + CHECK instead of native ENUM.

Triggers

def upgrade():
    op.execute("""
        CREATE OR REPLACE FUNCTION update_modified_column()
        RETURNS TRIGGER AS $$
        BEGIN
            NEW.updated_at = now();
            RETURN NEW;
        END;
        $$ LANGUAGE plpgsql;
    """)
    op.execute("""
        CREATE TRIGGER set_updated_at
        BEFORE UPDATE ON users
        FOR EACH ROW
        EXECUTE FUNCTION update_modified_column();
    """)

def downgrade():
    op.execute("DROP TRIGGER IF EXISTS set_updated_at ON users")
    op.execute("DROP FUNCTION IF EXISTS update_modified_column")

Idempotent migrations

For migrations that may run on different schema states:

def upgrade():
    op.execute("""
        DO $$
        BEGIN
            IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ix_users_email') THEN
                CREATE INDEX ix_users_email ON users (email);
            END IF;
        END
        $$;
    """)

PG-specific. For DB-agnostic: inspector check.

Generally: Alembic’s revision tracking ensures one run per migration. Idempotency is for edge cases.

Long-running migrations

For migrations that take hours: split into multiple revisions or run as a separate process while app is running.

# 1. Add nullable column
def upgrade():
    op.add_column("orders", sa.Column("status", sa.String(20), nullable=True))

# Then deploy code that handles NULL status as "pending"
# Then run a one-off backfill outside Alembic in batches
# Then:

# 2. Make NOT NULL
def upgrade():
    op.alter_column("orders", "status", nullable=False)

See Database Migrations 2026 .

Common mistakes

1. Importing app models in migration

Migration runs against future schema; imports may fail. Use frozen tables.

2. Big UPDATE

UPDATE users SET status = ... on 100M rows: hours of lock. Batch outside Alembic.

3. Non-CONCURRENTLY index on hot table

Locks. Use CONCURRENTLY.

4. Forgetting downgrade

Stub with pass is OK in prod. Make sure dev can roll back.

5. Mixing DDL and DML in transactions awkwardly

Some DDL (CONCURRENTLY, certain ENUM ops) can’t run in transactions. Configure.

What’s next

Chapter 4: branching and merging.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .