Cheatsheet of common migration recipes.
Add NOT NULL column (PG 11+)
def upgrade():
op.add_column("users",
sa.Column("status", sa.String(20), nullable=False, server_default="active"))
def downgrade():
op.drop_column("users", "status")
PG 11+ doesn’t rewrite the table for nullable→NOT NULL with server_default.
Add NOT NULL with backfill
def upgrade():
op.add_column("users", sa.Column("status", sa.String(20), nullable=True))
op.execute("UPDATE users SET status = 'active'")
op.alter_column("users", "status", nullable=False)
def downgrade():
op.drop_column("users", "status")
Rename column (preserves data)
def upgrade():
op.alter_column("users", "name", new_column_name="full_name", existing_type=sa.String(120))
def downgrade():
op.alter_column("users", "full_name", new_column_name="name", existing_type=sa.String(120))
Add FK
def upgrade():
op.create_foreign_key(
"fk_posts_user", "posts", "users",
["user_id"], ["id"],
ondelete="CASCADE",
)
def downgrade():
op.drop_constraint("fk_posts_user", "posts", type_="foreignkey")
For big tables (PG):
def upgrade():
op.create_foreign_key(
"fk_posts_user", "posts", "users",
["user_id"], ["id"],
postgresql_not_valid=True,
)
op.execute("ALTER TABLE posts VALIDATE CONSTRAINT fk_posts_user")
Add concurrent index (PG)
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")
def downgrade():
op.execute("DROP INDEX CONCURRENTLY ix_users_email")
In alembic.ini:
transaction_per_migration = false
Add unique partial index (soft-delete pattern)
def upgrade():
op.execute("""
CREATE UNIQUE INDEX CONCURRENTLY ix_users_email_active
ON users (email) WHERE deleted_at IS NULL
""")
def downgrade():
op.execute("DROP INDEX CONCURRENTLY ix_users_email_active")
Add CHECK constraint
def upgrade():
op.create_check_constraint("ck_orders_amount_positive", "orders", "amount > 0")
def downgrade():
op.drop_constraint("ck_orders_amount_positive", "orders", type_="check")
Create JSONB column with GIN
def upgrade():
op.add_column("events", sa.Column("payload", postgresql.JSONB(), nullable=False, server_default="{}"))
op.execute("""
CREATE INDEX CONCURRENTLY ix_events_payload_gin ON events
USING gin (payload jsonb_path_ops)
""")
def downgrade():
op.execute("DROP INDEX CONCURRENTLY ix_events_payload_gin")
op.drop_column("events", "payload")
Add ENUM (PG native)
def upgrade():
op.execute("CREATE TYPE user_role AS ENUM ('user', 'admin')")
op.add_column("users",
sa.Column("role", postgresql.ENUM("user", "admin", name="user_role", create_type=False),
nullable=False, server_default="user"))
def downgrade():
op.drop_column("users", "role")
op.execute("DROP TYPE user_role")
Add value to ENUM (PG 12+)
def upgrade():
op.execute("ALTER TYPE user_role ADD VALUE 'editor'")
def downgrade():
# No-op (can't remove enum values)
pass
Install extension
def upgrade():
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
def downgrade():
op.execute("DROP EXTENSION IF EXISTS pg_trgm")
Trigger / function (Postgres)
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")
View
def upgrade():
op.execute("""
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL
""")
def downgrade():
op.execute("DROP VIEW IF EXISTS active_users")
For replaceable views: drop + create in same migration on changes.
Materialized view
def upgrade():
op.execute("""
CREATE MATERIALIZED VIEW user_stats AS
SELECT user_id, count(*) AS post_count FROM posts GROUP BY user_id
""")
op.execute("CREATE UNIQUE INDEX ON user_stats (user_id)")
def downgrade():
op.execute("DROP MATERIALIZED VIEW IF EXISTS user_stats")
Refresh:
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;
Partitioned table
def upgrade():
op.execute("""
CREATE TABLE events (
id bigserial,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at)
""")
op.execute("""
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
""")
def downgrade():
op.execute("DROP TABLE events")
Drop column (after deploy stops referencing it)
def upgrade():
op.drop_column("users", "old_field")
def downgrade():
op.add_column("users", sa.Column("old_field", sa.String, nullable=True))
Bulk data fix in migration
def upgrade():
bind = op.get_bind()
# Don't import ORM models — they evolve
users = sa.table("users",
sa.column("id", sa.Integer),
sa.column("status", sa.String),
)
bind.execute(users.update().where(users.c.status == None).values(status="active"))
For large tables: backfill outside Alembic (in batches).
Read this next
If you want my Alembic recipe library (all the above), it’s at rajpoot.dev .
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 .