Cheatsheet for the SQLAlchemy + Alembic integration. Long-form: Alembic textbook .
Init
alembic init -t async migrations
alembic.ini
[alembic]
script_location = migrations
sqlalchemy.url = # leave empty; set from env.py
env.py (async)
import asyncio
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import all model modules so Base.metadata is populated
import src.app.models # noqa
from src.app.db import Base
from src.app.settings import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # detect type changes
compare_server_default=True, # detect default changes
include_schemas=False, # toggle for multi-schema
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
)
async with connectable.connect() as conn:
await conn.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
raise NotImplementedError
else:
asyncio.run(run_migrations_online())
Naming conventions (in Base.metadata)
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
Without these, autogenerated migrations get names like ck_1.
Common commands
alembic revision --autogenerate -m "msg"
alembic upgrade head
alembic upgrade +1
alembic downgrade -1
alembic current
alembic history
alembic heads
alembic show <rev>
alembic stamp head # mark current without running
Multi-head check (CI)
[ $(alembic heads | wc -l) -gt 1 ] && exit 1
Drift test
def test_no_drift(engine):
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
with engine.sync_engine.connect() as conn:
ctx = MigrationContext.configure(conn)
diff = compare_metadata(ctx, Base.metadata)
assert diff == [], f"Drift: {diff}"
op API quick reference
from alembic import op
import sqlalchemy as sa
op.create_table("users", ...)
op.drop_table("users")
op.add_column("users", sa.Column("status", sa.String(20), nullable=False, server_default="active"))
op.drop_column("users", "status")
op.alter_column("users", "name", type_=sa.String(200), nullable=False, new_column_name="full_name")
op.rename_table("old", "new")
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.drop_index("ix_users_email", "users")
op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_check_constraint("ck_users_age", "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")
op.execute("SQL ...")
Postgres CONCURRENTLY
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")
Configure (can’t run in transaction):
[alembic]
transaction_per_migration = false
Data migration pattern
def upgrade():
op.add_column("users", sa.Column("status", sa.String(20), nullable=True))
# Lightweight table inside the migration (don't import ORM models)
users = sa.table("users",
sa.column("id", sa.Integer),
sa.column("status", sa.String),
)
bind = op.get_bind()
bind.execute(users.update().values(status="active"))
op.alter_column("users", "status", nullable=False)
Run as K8s Job (pre-deploy)
apiVersion: batch/v1
kind: Job
metadata: { name: migrate-{{ .Values.image.tag }} }
spec:
template:
spec:
containers:
- name: migrate
image: myapi:{{ .Values.image.tag }}
command: ["alembic", "upgrade", "head"]
restartPolicy: OnFailure
Expand-contract for safety
Phase 1 (expand): add new column; backfill; code writes both.
Phase 2 (contract): code uses new only; drop old.
Each is its own migration + deploy.
Common mistakes
- Models not imported → empty autogenerate.
compare_type=False(default) → missed type changes.- Renames shown as drop+add → manual
alter_column(... new_column_name=...). - CONCURRENTLY in transaction → fails.
- App runs migrations from N replicas → race.
Read this next
If you want my Alembic env.py + naming-convention starter, 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 .