Cheatsheet for Alembic in CI.
GitHub Actions
name: Migrations
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports: ["5432:5432"]
options: --health-cmd="pg_isready" --health-interval=10s
env:
DATABASE_URL: postgresql+asyncpg://postgres:test@localhost:5432/test
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_migrations.py
- run: |
if [ "$(uv run alembic heads | wc -l)" -gt 1 ]; then exit 1; fi
Drift test
# tests/test_migrations.py
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy import create_engine
def test_no_drift():
eng = create_engine(DATABASE_URL.replace("+asyncpg", ""))
with eng.connect() as conn:
ctx = MigrationContext.configure(conn)
diff = compare_metadata(ctx, Base.metadata)
assert diff == [], f"Drift detected: {diff}"
Catches forgotten migrations. PR fails until you generate.
Round-trip test
def test_migrate_up_down():
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
command.upgrade(cfg, "head")
command.downgrade(cfg, "base")
command.upgrade(cfg, "head")
Validates downgrades work (even if you never use them in prod).
Single-head gate
if [ "$(alembic heads | wc -l)" -gt 1 ]; then
echo "Multiple heads — merge required"
exit 1
fi
Required for clean deploys.
Dry-run SQL
alembic upgrade head --sql > migration.sql
Generates SQL without applying. For review or running manually.
Migration shape test
import os
def test_migration_files_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()
# Must have docstring
assert '"""' in content
# Must have upgrade and downgrade
assert "def upgrade()" in content
assert "def downgrade()" in content
CI for multi-DB
- run: alembic --name users upgrade head
- run: alembic --name analytics upgrade head
- run: pytest tests/test_drift_users.py
- run: pytest tests/test_drift_analytics.py
Apply in deploy (K8s Job)
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-{{ .Values.image.tag }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
containers:
- name: migrate
image: myapp:{{ .Values.image.tag }}
command: ["alembic", "upgrade", "head"]
envFrom:
- secretRef: { name: app-secrets }
restartPolicy: OnFailure
backoffLimit: 3
ttlSecondsAfterFinished: 300
Helm hook ensures Job runs before app pods. Idempotent (Alembic skips already-applied).
Migration verification post-deploy
# Health endpoint
@app.get("/migration_status")
async def status(db = Depends(get_db)):
rev = await db.scalar(text("SELECT version_num FROM alembic_version"))
return {"current_revision": rev, "expected": EXPECTED_HEAD}
Catches deploy/migration mismatches.
Pre-commit hook
# .pre-commit-config.yaml
- repo: local
hooks:
- id: alembic-single-head
name: alembic-single-head
entry: bash -c 'if [ "$(alembic heads | wc -l)" -gt 1 ]; then exit 1; fi'
language: system
pass_filenames: false
Block commits with multiple heads.
SQL-out for code review
- run: alembic upgrade --sql head > migration.sql
- uses: actions/upload-artifact@v3
with:
name: migration-sql
path: migration.sql
Reviewers see the actual SQL.
Long migration warning
def test_no_slow_ops():
# Read latest migration file
latest = sorted(os.listdir("migrations/versions"))[-1]
with open(f"migrations/versions/{latest}") as f:
content = f.read()
# Heuristic: warn on potentially slow ops without CONCURRENTLY
assert "CREATE INDEX " not in content or "CONCURRENTLY" in content
Catches risky migrations.
Common mistakes
- CI without a real DB — only Python errors caught, not SQL.
- No drift test — models drift unnoticed.
- Running migrations from app startup with N replicas — race.
- Migrations + tests against different DBs — different errors.
Read this next
If you want my GitHub Actions migration workflow + tests, 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 .