Chapter 7: Alembic in CI. Drift detection, round-trip tests, deploy gates, pre-merge checks.
CI fundamentals
Run Alembic against a fresh DB in CI. Catches:
- Migration syntax errors.
- Column / type mismatches.
- Missing imports in env.py.
- Drift between models and migrations.
Postgres in CI
# GitHub Actions
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
env:
DATABASE_URL: postgresql+asyncpg://postgres:test@localhost:5432/test
Or testcontainers. PG service is faster for CI.
Drift detection
Test that models and migrations are in sync:
# tests/test_alembic.py
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
def test_no_drift(engine):
# First, apply all migrations
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", str(engine.url))
command.upgrade(cfg, "head")
# Then compare
with engine.connect() as conn:
ctx = MigrationContext.configure(conn)
diff = compare_metadata(ctx, Base.metadata)
assert diff == [], f"Drift: {diff}"
If diff is non-empty: someone changed models without generating a migration. Test fails.
Round-trip test
Verify migrations work both ways:
def test_migrate_up_down(engine):
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", str(engine.url))
command.upgrade(cfg, "head")
command.downgrade(cfg, "base")
command.upgrade(cfg, "head")
If any downgrade is broken or non-reversible: caught here.
For prod (forward-only) it’s still useful — downgrades are dev/test convenience.
Single-head check
- run: |
if [ $(alembic heads | wc -l) -gt 1 ]; then
echo "Multiple heads"
exit 1
fi
Block PRs that introduce a branch without a merge.
Dry-run
alembic upgrade head --sql > migration.sql
Prints SQL without applying. Useful for review.
Migration linting
For consistent migration style:
# tests/test_migration_style.py
import os
def test_all_migrations_have_message():
for fname in os.listdir("migrations/versions"):
if not fname.endswith(".py"): continue
with open(f"migrations/versions/{fname}") as f:
content = f.read()
assert '"""' in content # has docstring
# ... other checks
Or use alembic-utils for view / function management with linting.
Pre-merge checks
GitHub Actions workflow:
name: Migrations
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
# ...
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: uv sync --frozen
- run: uv run alembic upgrade head
- run: uv run pytest tests/test_alembic.py
- run: |
if [ $(uv run alembic heads | wc -l) -gt 1 ]; then exit 1; fi
Block merge until all migrations green.
Apply on deploy
K8s Job runs migrations before app rolls out:
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-{{ .Values.image.tag }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
spec:
template:
spec:
containers:
- name: migrate
image: myapp:{{ .Values.image.tag }}
command: ["alembic", "upgrade", "head"]
envFrom:
- secretRef: { name: myapp-secrets }
restartPolicy: OnFailure
backoffLimit: 3
Helm hook ensures Job runs before pod rollout. Job idempotent — Alembic skips already-applied.
Migration timing
For schemas that take long: run as a separate Job; deploy app after Job completes.
For really long: schedule for off-hours; communicate to ops.
Rollback strategy
Production: don’t downgrade. Instead, roll forward with a corrective migration.
If you must roll back:
- Stop traffic.
alembic downgrade -1.- Roll back app deploy.
- Investigate.
For most cases: roll forward is safer.
Migration history snapshot
For audit: every applied migration logged:
SELECT * FROM alembic_version;
-- one row: current head
For history of who-applied-when: extend the version table or use deploy logs / audit log.
Common mistakes
1. CI without DB
Migration tests must run against a real DB.
2. No drift test
Models change; migrations forgotten; deploys break.
3. Forgetting to commit lock changes
If you generate a migration but don’t commit migrations/versions/...py — CI catches; PR may not.
4. Migrating in app startup with multiple replicas
Race conditions; conflicting locks. Use a Job.
5. Long migrations blocking deploy
CI passes; production migration takes 2 hours; deploy stuck. Detect long migrations in staging first.
What’s next
Chapter 8: production patterns and recovery.
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 .