Running Alembic migrations in CI/CD pipelines

Run migrations in a dedicated pre-deploy job holding a PostgreSQL advisory lock, not at application start-up — with several replicas starting at once, start-up migrations race, and the loser either fails or applies the same DDL twice. This is the deployment half of managing multiple heads and database branching.

Quick Answer

Eight replicas, or one job Two deployment shapes. With migrations at application start-up, a rollout that replaces eight replicas has eight processes attempting the same upgrade within a second of each other. PostgreSQL serialises the DDL, so most of them fail with a duplicate-object error, and the ones that crash are restarted by the orchestrator into the same race. With a dedicated pre-deploy job, the upgrade runs exactly once, the job succeeds or fails visibly, and the new replicas start only afterwards — so a failed migration blocks the rollout rather than producing a crash loop that has to be diagnosed from container logs. migrations at application start-up 8 replicas, 8 simultaneous upgrades most fail with a duplicate object the orchestrator restarts them into the race diagnosed from container logs, badly a dedicated pre-deploy job runs exactly once succeeds or fails visibly replicas start only after it finishes a failed migration blocks the rollout If a dedicated job is genuinely impossible, an advisory lock at start-up makes the race safe — but it does not make a failure visible, which is half the value.

Before — migrating at application start-up:

@app.on_event("startup")
async def migrate() -> None:
    await asyncio.to_thread(command.upgrade, config, "head")   # every replica does this

After — a dedicated job holding an advisory lock:

"""deploy/migrate.py — run once per deploy, before the new replicas start."""
from __future__ import annotations

import asyncio
import os
import sys

from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine

# Any stable 64-bit integer; it names this application's migration lock.
MIGRATION_LOCK_KEY = 0x5A1CE_M1G8 & 0x7FFFFFFFFFFFFFFF


async def main() -> int:
    url = os.environ["DATABASE_URL"]
    config = Config("alembic.ini")
    config.set_main_option("sqlalchemy.url", url.replace("asyncpg", "psycopg2"))

    heads = ScriptDirectory.from_config(config).get_heads()
    if len(heads) != 1:
        print(f"refusing to migrate: {len(heads)} heads ({', '.join(heads)})",
              file=sys.stderr)
        return 2

    engine = create_async_engine(url, poolclass=None)
    try:
        async with engine.connect() as connection:
            # Session-level: released automatically if this job is killed.
            await connection.execute(
                text("SELECT pg_advisory_lock(:key)"), {"key": MIGRATION_LOCK_KEY}
            )
            await asyncio.to_thread(command.upgrade, config, "head")
    finally:
        await engine.dispose()
    return 0


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main()))

The job exits non-zero on failure, which is what makes the rollout stop rather than proceeding with replicas that expect a schema the database does not have.

Execution Context & Async Workflow Integration

Alembic's command API is synchronous even when env.py drives an async engine, so the job above runs command.upgrade in a thread. That is not a workaround — the migration genuinely is a blocking operation with nothing else to interleave with — but it does mean the advisory lock has to be held on a different connection from the one Alembic uses, which is exactly what the code above does.

The lock key deserves a moment's thought. pg_advisory_lock takes a 64-bit integer and there is no registry, so two applications sharing a database can collide by accident. Deriving the key from the application name removes that:

import hashlib

def lock_key(application: str) -> int:
    digest = hashlib.sha256(application.encode()).digest()[:8]
    return int.from_bytes(digest, "big", signed=True)


MIGRATION_LOCK_KEY = lock_key("orders-api")

For the case where a dedicated job genuinely is not available — a platform that only runs containers, with no pre-deploy hook — the same lock makes start-up migrations safe:

# Async — start-up migration that is safe with N replicas
async def migrate_on_startup(url: str) -> None:
    engine = create_async_engine(url, poolclass=None)
    try:
        async with engine.connect() as connection:
            # try_advisory_lock returns immediately: whoever gets it migrates,
            # everyone else proceeds to start up and serve.
            got = await connection.scalar(
                text("SELECT pg_try_advisory_lock(:key)"), {"key": MIGRATION_LOCK_KEY}
            )
            if not got:
                return
            await asyncio.to_thread(command.upgrade, config, "head")
    finally:
        await engine.dispose()

pg_try_advisory_lock rather than pg_advisory_lock is deliberate: the replicas that do not get the lock should start serving rather than blocking behind the migration. The weakness compared with a dedicated job remains — a failure is logged by one replica among many and does not stop the rollout — which is why this is the fallback rather than the recommendation.

Four checks, all of them cheap Four pipeline steps, none needing more than a container. Asserting a single head catches the second branch nobody noticed, before it becomes a deploy failure. Rejecting edits to revision files already on the main branch prevents the rebase that breaks other environments. Applying every migration to a fresh database proves they run at all, which sounds obvious and catches a surprising number of revisions that were only ever tested against a database that already had the change. And comparing the models against the resulting schema proves the migration matches what the code expects, which is the check that catches a hand-edited revision drifting from its models. assert exactly one head alembic heads returns one line catches the second branch before it becomes a deploy failure reject edits to released revisions git diff against main over alembic/versions prevents the rebase that breaks every other environment apply every migration to a fresh database alembic upgrade head from empty proves they run — more revisions fail this than teams expect compare models to the resulting schema compare_metadata after the upgrade catches a hand-edited revision drifting from its models All four run without production access and in well under a minute, which is what makes them worth running on every pull request rather than only before a release.

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
DuplicateColumn / DuplicateTable during a rolloutSeveral replicas migrated simultaneously.A dedicated job, or an advisory lock at start-up.
The deploy proceeds despite a failed migrationThe migration ran inside the application, so its failure looked like one crashed replica.Run it as a job whose exit status gates the rollout.
The migration job hangs indefinitelyIt is waiting on a lock held by a long-running transaction, often an open session in a console.Set lock_timeout on the migration connection so it fails fast instead of blocking the deploy.
Multiple head revisions are present at deploy timeThe single-head check is not in the pull-request pipeline.Add it; it needs no database and takes seconds.
A revision applies in CI and fails in productionCI built the schema with create_all rather than by running migrations.Apply migrations from empty in CI, which is the only way to test them.
The advisory lock is still held after a crashA transaction-level lock was used inside a transaction that is still open.Use the session-level pg_advisory_lock; it is released when the connection closes.

Advanced: Reviewable SQL and a Gate for Risky Operations

For any environment where a DBA reviews changes, Alembic's offline mode prints the SQL without running it — and emitting that into the build log gives an auditable record of exactly what a deploy was about to do.

# Emit the SQL for the range being applied, without connecting
$ alembic upgrade c3a1029ef455:head --sql | tee migration.sql

That output is also the right input for an automated risk gate. Certain operations should never appear in a deploy that happens without a human present:

"""Fail the pipeline when a migration contains an operation that needs a person."""
from __future__ import annotations

import re
import sys

RISKY = {
    r"\bDROP\s+TABLE\b": "drops a table",
    r"\bDROP\s+COLUMN\b": "drops a column",
    r"\bALTER\s+COLUMN\b.*\bTYPE\b": "changes a column type",
    r"\bCREATE\s+INDEX\b(?!\s+CONCURRENTLY)": "builds an index without CONCURRENTLY",
    r"\bUPDATE\b(?!.*\bWHERE\b)": "updates every row of a table",
}


def check(sql: str) -> int:
    findings = [
        why for pattern, why in RISKY.items()
        if re.search(pattern, sql, re.IGNORECASE | re.DOTALL)
    ]
    for why in findings:
        print(f"BLOCKED: this migration {why}", file=sys.stderr)
    return 1 if findings else 0


if __name__ == "__main__":
    raise SystemExit(check(sys.stdin.read()))

The point is not that these operations are forbidden — sometimes a column really does need dropping — but that they should require an explicit acknowledgement rather than riding along in a routine deploy. A label on the pull request, or an environment variable set deliberately in a manual run, is enough to bypass the gate with a record of who decided.

The CREATE INDEX check is the one that earns its keep most often. An index built without CONCURRENTLY takes a lock that blocks writes for the duration, which on a large table is an outage nobody planned — and the fix, covered in zero-downtime schema migration strategies, also requires transaction_per_migration in env.py, so the gate catches two related mistakes at once.

The deploy job, step by step Five steps. The job takes a PostgreSQL session-level advisory lock, so a second runner started by a retry or a parallel pipeline waits rather than racing. It verifies exactly one head, failing fast with a message naming the revisions if not. It emits the SQL with the offline flag into the build log, which gives an auditable record of exactly what was about to run. It applies the upgrade. And it releases the lock — although the session-level lock is released automatically when the connection closes, which is what makes this safe even when the job is killed. acquire an advisory lock pg_advisory_lock on a fixed key a second runner waits verify a single head fail fast, naming the revisions before touching anything emit the SQL to the build log alembic upgrade head --sql an auditable record run the upgrade the only step that changes anything and the only one that can fail release the lock automatic when the connection closes The lock being session-level is what makes a killed job safe: the connection dies, the lock goes with it, and the next run is not blocked by a ghost.

When the Migration Job Fails

A failed migration job is a good outcome compared with the alternatives, because the rollout stops and the old replicas keep serving against the old schema. What matters next is knowing which state the database is in.

With the default single-transaction behaviour, a failure rolls the entire run back and alembic_version is unchanged — the database is exactly where it started, and the fix is to correct the revision and redeploy.

With transaction_per_migration=True, revisions before the failing one have committed, so the database is at an intermediate revision. alembic current tells you which. From there the choices are to fix the failing revision and re-run, which resumes from where it stopped, or to add a new revision that corrects the situation. Downgrading is rarely the right answer, for the reasons in [rolling back a failed Alembic migration in production](' + P + 'rolling-back-a-failed-alembic-migration-in-production/).

Two habits make the recovery quicker. Have the job log alembic current both before and after, so the build output records exactly where the database started and finished — this removes an entire round of "which environment is at which revision" during an incident. And set a lock_timeout on the migration connection:

await connection.execute(text("SET lock_timeout = '10s'"))

Without it, a migration needing a table lock waits behind whatever long-running transaction happens to exist — an open psql session, a slow report — and the deploy hangs rather than failing. With it, the job fails in ten seconds with a message naming the lock, which is a diagnosis rather than a mystery.

Frequently Asked Questions

Why not just run migrations at application start-up?

Because a rollout starts several replicas at once and they all attempt the same upgrade. PostgreSQL serialises the DDL, so most fail; the orchestrator restarts them into the same race. Even when an advisory lock makes it safe, a failure is buried in one replica log rather than stopping the deploy.

Should the migration job run before or after the new code is deployed?

Before, and this is why migrations must be backwards-compatible with the currently running version: during the rollout, old code runs against the new schema. That constraint is exactly what expand-and-contract exists to satisfy.

Is an advisory lock necessary if there is only one migration job?

It is cheap insurance. Pipelines get retried, two deploys get triggered close together, and someone runs the job by hand while a deploy is in flight. The lock costs one round trip and makes all of those safe.

How do I test the migrations themselves in CI?

Apply them to an empty database and then compare the result against your model metadata. Building the schema with create_all tests the models, not the migrations — and it is precisely a divergence between the two that a broken migration creates.