Chapter 10: schema evolution. SQLAlchemy doesn’t ship migrations; Alembic does. We cover the integration here; the Alembic textbook is the deep dive.
Why migrations
When your Base.metadata schema changes, the DB doesn’t auto-update. Migrations apply diffs to existing DBs.
Base.metadata.create_all(engine) works for fresh DBs in dev / tests. For production: Alembic.
Setup
uv add alembic
alembic init -t async migrations
migrations/ directory created. alembic.ini config; migrations/env.py setup.
Configuring env.py
# migrations/env.py
from src.myapp.db import Base
from src.myapp.settings import settings
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
Tells Alembic where the metadata lives.
Autogenerate
alembic revision --autogenerate -m "add users table"
Alembic compares Base.metadata to the DB; generates a migration file with the diff.
Always review autogenerated migrations. They miss things (CHECK changes, certain index changes, default value changes).
Apply migrations
alembic upgrade head
alembic downgrade -1
head = latest. -1 = previous revision.
Migration file shape
def upgrade():
op.create_table("users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False),
sa.UniqueConstraint("email"),
)
def downgrade():
op.drop_table("users")
Forward + reverse. Most teams keep downgrade for dev convenience; in prod, roll forward only.
Naming conventions
For predictable constraint names:
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 this, autogenerate may produce ambiguous names (e.g., ck_1).
Expand-contract pattern
For non-trivial schema changes (rename column, change type):
- Expand: add new column; backfill; code writes both; reads new with fallback.
- Contract: code reads/writes new only; drop old.
Each phase is its own migration + deploy. See Database Migrations 2026 .
Backfill
def upgrade():
op.add_column("users", sa.Column("status", sa.String(20), nullable=True))
# Backfill in Python (slow for big tables)
conn = op.get_bind()
conn.execute(text("UPDATE users SET status = 'active'"))
op.alter_column("users", "status", nullable=False)
For huge tables: backfill outside Alembic in batches; alter NOT NULL when complete.
Concurrent indexes (Postgres)
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")
Avoid table locks. Important for hot tables.
Note: CONCURRENTLY can’t run in a transaction. Use op.execute outside transaction:
# alembic.ini
[alembic]
transaction_per_migration = true
Then per-migration: op.execute(...) runs after commit.
Branching
For team workflows, branches happen:
A
├── B (your work)
└── C (their work)
Merge: alembic merge -m "merge B+C" B C
Alembic supports merge migrations. Best to keep history linear when possible.
Multiple databases
For apps with multiple databases:
alembic init -t multidb migrations
env.py iterates databases. Each gets its own version table.
Embedded migration runs
from alembic import command
from alembic.config import Config
@asynccontextmanager
async def lifespan(app):
cfg = Config("alembic.ini")
command.upgrade(cfg, "head")
yield
Run on startup. Simple but blocks startup until done.
For multi-replica K8s deploys: only one should run migrations. Use a Job:
apiVersion: batch/v1
kind: Job
metadata: { name: migrate-{{ .Values.image.tag }} }
spec:
template:
spec:
containers:
- name: migrate
image: myapp:{{ .Values.image.tag }}
command: ["alembic", "upgrade", "head"]
restartPolicy: OnFailure
Pre-deploy job; then rolling deploy of app.
Testing migrations
In CI:
alembic upgrade head # apply all
pytest # run tests against migrated DB
Or shape tests to detect drift between metadata and DB:
def test_no_drift():
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
with engine.connect() as conn:
ctx = MigrationContext.configure(conn)
diff = compare_metadata(ctx, Base.metadata)
assert diff == [], f"Drift: {diff}"
Catches forgotten migrations in CI.
What Alembic doesn’t do
- Big-table online schema changes (use pt-online-schema-change for MySQL, careful expand-contract for Postgres).
- Data migrations at scale (do them as separate one-off jobs).
- Cross-DB migration.
What’s next
Chapter 11: Testing.
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 .