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 .