Skip to content

Fix: Alembic Not Working — Autogenerate Missing Changes, Multiple Heads, and Migration Conflicts

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Alembic errors — autogenerate not detecting model changes, Multiple head revisions, can't locate revision, downgrade fails, async engine support, and database URL configuration.

The Error

You run alembic revision --autogenerate and it misses your model changes:

$ alembic revision --autogenerate -m "add email column"
INFO  [alembic.autogenerate.compare] Detected added column 'users.email'
# Generated migration is empty — column not added

Or you have multiple branch heads:

ERROR: Multiple head revisions are present for given argument 'head'.
Specify a specific target revision, '<branchname>@head' to narrow to a specific head,
or 'heads' for all heads

Or alembic upgrade can’t find a revision:

ERROR: Can't locate revision identified by 'abc123def456'

Or autogenerate generates spurious operations on every run:

def upgrade():
    op.alter_column("users", "email", existing_type=sa.VARCHAR(length=255), nullable=True)
    op.alter_column("users", "email", existing_type=sa.VARCHAR(length=255), nullable=True)
    # Same op generated over and over because Alembic thinks something changed

Or async engine support breaks:

NotImplementedError: This is a synchronous API

Alembic is THE database migration tool for SQLAlchemy projects — generates schema diffs into Python migration scripts, tracks applied migrations in a database table, supports upgrades and downgrades. It’s powerful but the autogenerate engine has well-known blind spots (enum changes, server defaults, custom types), and the branch/head model from Mercurial/Git is unfamiliar to many developers. This guide covers each common failure.

Why This Happens

Alembic’s autogenerate compares your SQLAlchemy models against the database’s current schema by introspecting both. The comparison is approximate — it catches column adds/drops, table creations, and most type changes, but misses constraints with implementation-specific names, server defaults across DBs, and many custom types. The “empty migration generated” symptom usually means the model and database actually agree from Alembic’s perspective.

Branch heads happen when two developers create migrations from the same parent revision in parallel — both branches point to different “heads.” Merging requires an explicit merge migration.

In Production: Incident Lens

A failed Alembic migration in production is one of the worst outages to recover from. The deploy is stuck (rollout halted mid-step), and the schema is half-applied — some tables have the new columns, some don’t. The blast radius is the worst case: deploy stuck or, worse, the database is in a state that no version of the application code can serve correctly. Old pods crash on missing columns; new pods crash on missing tables.

Monitoring signals. Watch for: deploy steps timing out on the migration job specifically (not the app start), PostgreSQL pg_locks showing an AccessExclusiveLock on a table for more than a few seconds (DDL waiting on a long-running query), and alembic current returning a revision that isn’t in alembic heads after the deploy “succeeded” (silent partial apply). The most insidious signal is the absence of one: a migration that ran outside a transaction, partially committed DDL, and exited 0 because Alembic thinks each statement is its own success. PostgreSQL DDL is transactional unless you op.execute("COMMIT") mid-migration — and people do that for ALTER TYPE ... ADD VALUE or for CREATE INDEX CONCURRENTLY, both of which are the exact statements that fail and leave you split.

Blast radius. Old code expects the old schema; new code expects the new schema. Mid-migration, neither version fully works. Reads that join the modified table return errors. Writes that target a renamed column fail. Background workers and cron jobs hit the same crash loop — see Kubernetes CrashLoopBackOff when pods can’t reach a steady state. If you have multiple services pointing at the same database, every one of them is affected, not just the service that owns the migration.

Recovery sequence. Step 1: stop new pods from rolling out (kubectl rollout pause or equivalent). Step 2: check SELECT * FROM alembic_version and compare against alembic history to figure out exactly where the migration stopped. Step 3: decide forward or back. Forward is safer if the failure was a transient (lock timeout, network blip) — re-run alembic upgrade head and let it resume. Back is required if the failure was structural (e.g., the migration’s DDL was invalid against current data). For back, never run alembic downgrade -1 if the migration left non-atomic DDL behind; instead, write a hand-rolled SQL recovery that puts the schema in a known good state, then alembic stamp <prior_revision> to align the version table. Step 4: roll the application back to the version that matches the recovered schema. Step 5: ship a corrected migration before resuming the rollout.

Postmortem preventives. Always run alembic upgrade head as a separate, gated pre-deploy step (not interleaved with app start) so a migration failure halts before any new pods come up. Set transaction_per_migration=True in env.py so each migration is its own transaction — a failure rolls back instead of leaving half-applied DDL. For zero-downtime, follow the expand-contract pattern: every migration must be backward-compatible with the previous release, not just the current one. Run pg_dump --schema-only against staging before and after every migration, diff the output, and reject PRs whose actual DDL doesn’t match the intended diff. For the matching CI-side guardrails on the deployment side, see GitHub Actions process completed exit code 1.

Fix 1: Initial Setup

# In your project root, with SQLAlchemy already installed
pip install alembic
alembic init alembic

Resulting structure:

my-project/
├── alembic/
│   ├── env.py          # Configuration code (edit this)
│   ├── script.py.mako  # Template for new migrations
│   └── versions/       # Migration scripts live here
├── alembic.ini         # Config file
└── mymodels.py

Configure database URL in alembic.ini:

# alembic.ini
sqlalchemy.url = postgresql://user:pass@localhost/mydb

Better — read from environment variable in env.py:

# alembic/env.py
import os
from alembic import context
from sqlalchemy import engine_from_config, pool

config = context.config

# Override URL from env if available
db_url = os.getenv("DATABASE_URL")
if db_url:
    config.set_main_option("sqlalchemy.url", db_url)

# ... rest of env.py

Connect your models for autogenerate:

# alembic/env.py
from myapp.database import Base   # SQLAlchemy declarative base
import myapp.models   # Import all models so they register on Base.metadata

target_metadata = Base.metadata

Common Mistake: Forgetting to import myapp.models in env.py. Without it, your SQLAlchemy Base.metadata is empty — autogenerate sees no models to compare against and produces empty migrations. Always import the modules that define your models so they get registered.

Fix 2: Creating Migrations

# Generate from model diff
alembic revision --autogenerate -m "add users table"

# Generate empty migration to write by hand
alembic revision -m "custom data migration"

Run migrations:

alembic upgrade head           # Upgrade to latest
alembic upgrade +1             # Upgrade one step
alembic upgrade abc123         # Upgrade to specific revision

alembic downgrade -1           # Downgrade one step
alembic downgrade base         # Roll back all migrations
alembic downgrade abc123       # Downgrade to specific revision

Inspect state:

alembic current                # Show current revision
alembic history                # Show all revisions
alembic heads                  # Show current head(s)
alembic show abc123            # Show specific revision details

A typical migration file:

# alembic/versions/abc123_add_users_table.py
"""add users table

Revision ID: abc123
Revises:
Create Date: 2025-04-24 10:00:00
"""
from alembic import op
import sqlalchemy as sa

revision = "abc123"
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    op.create_table(
        "users",
        sa.Column("id", sa.Integer(), primary_key=True),
        sa.Column("email", sa.String(255), nullable=False, unique=True),
        sa.Column("name", sa.String(100), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
    )
    op.create_index("ix_users_email", "users", ["email"])

def downgrade():
    op.drop_index("ix_users_email", "users")
    op.drop_table("users")

Always write a downgrade() — even if you don’t expect to roll back, having a tested downgrade catches bugs in the upgrade (asymmetry usually means something is wrong).

Fix 3: Autogenerate Limitations

Autogenerate catches some changes and misses others:

Detected:

  • Table creation and drop
  • Column adds, drops, renames (via comment hints)
  • Index creation/drop (with limitations on naming)
  • Foreign key creation/drop
  • Most type changes

NOT detected (you must write manually):

  • Constraint changes that don’t change the SQL signature
  • Server defaults (some DBs report them differently than SQLAlchemy declares)
  • Enum value changes (PostgreSQL ENUM types)
  • Comment changes
  • Custom types you’ve defined
  • Data migrations (transforming existing rows)

Configure autogenerate to be smarter:

# alembic/env.py
def run_migrations_online():
    connectable = engine_from_config(...)

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            compare_type=True,           # Detect column type changes
            compare_server_default=True,  # Detect server_default changes
            include_schemas=True,         # Detect schema changes
            render_as_batch=True,         # For SQLite ALTER limitations
        )

        with context.begin_transaction():
            context.run_migrations()

compare_type=True — catches String(50) → String(100) and similar. Off by default because some type comparisons are noisy.

compare_server_default=True — catches server_default=func.now() additions or removals.

Pro Tip: Always set both compare_type and compare_server_default to True. The “noisier” autogenerate catches real changes that the default conservative settings miss. False positives are easier to spot and delete than missing changes that ship to production.

Fix 4: Multiple Heads

ERROR: Multiple head revisions are present

Two developers created migrations from the same parent in parallel:

        A (parent)
       / \
      B   C   ← Two heads

Inspect:

alembic heads
# abc123 (head)
# def456 (head)

Merge them with a merge migration:

alembic merge -m "merge heads" abc123 def456
# Creates a new migration with both abc123 and def456 as parents
        A
       / \
      B   C
       \ /
        D   ← Merge revision, new single head
alembic upgrade head   # Now works

Avoid multiple heads by communicating with your team or rebasing migrations:

# Drop your local migration, pull latest, regenerate
git checkout main -- alembic/versions/
alembic revision --autogenerate -m "your change"

Common Mistake: Letting multiple heads accumulate in the repo. Each merge migration adds complexity. The clean pattern is to enforce single-head policy in CI:

# In CI
test $(alembic heads | wc -l) -eq 1 || { echo "Multiple heads detected"; exit 1; }

Fix 5: Custom Types and Enums

PostgreSQL ENUMs need special handling:

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

status_enum = postgresql.ENUM("active", "inactive", "deleted", name="user_status")

def upgrade():
    # Create enum type first
    status_enum.create(op.get_bind())

    op.create_table(
        "users",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("status", status_enum, nullable=False),
    )

def downgrade():
    op.drop_table("users")
    status_enum.drop(op.get_bind())   # Drop enum AFTER table

Adding enum values (PostgreSQL doesn’t support ALTER TYPE ADD VALUE in a transaction by default):

def upgrade():
    # Outside a transaction — PostgreSQL requirement
    op.execute("COMMIT")
    op.execute("ALTER TYPE user_status ADD VALUE 'pending'")

def downgrade():
    # Enum value removal is HARD — usually requires renaming the type
    op.execute("COMMIT")
    op.execute("ALTER TYPE user_status RENAME TO user_status_old")
    op.execute("CREATE TYPE user_status AS ENUM ('active', 'inactive', 'deleted')")
    op.execute("""
        ALTER TABLE users
        ALTER COLUMN status TYPE user_status
        USING status::text::user_status
    """)
    op.execute("DROP TYPE user_status_old")

Workaround: configure Alembic to run outside the migration transaction:

# alembic/env.py
def run_migrations_online():
    # ...
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        transaction_per_migration=True,   # Each migration in its own tx
    )

Fix 6: Async SQLAlchemy Support

NotImplementedError: This is a synchronous API

If your app uses AsyncEngine, you need an async-aware env.py:

# alembic/env.py
import asyncio
from sqlalchemy.ext.asyncio import async_engine_from_config

def run_migrations_online():
    asyncio.run(run_async_migrations())

async def run_async_migrations():
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()

def do_run_migrations(connection):
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
    )

    with context.begin_transaction():
        context.run_migrations()

Use a sync URL for migrations even if your app uses async:

# alembic.ini
sqlalchemy.url = postgresql+psycopg://user:pass@localhost/mydb  # Sync driver
# Not: postgresql+asyncpg://...

Alembic doesn’t require an async driver — running migrations via sync psycopg is fine even if your app uses asyncpg. This avoids the async env.py boilerplate entirely.

In async SQLAlchemy applications that use asyncpg as the driver, keep migrations on the sync psycopg path even if the app runs on asyncpg — Alembic’s autogenerate and op.execute machinery are sync-native, and routing them through an async engine adds complexity for zero benefit.

Fix 7: Data Migrations

For migrations that transform existing data (not just schema):

"""migrate user names to first/last

Revision ID: def456
Revises: abc123
"""
from alembic import op
import sqlalchemy as sa

revision = "def456"
down_revision = "abc123"

# Define helper tables with just the columns this migration touches
users = sa.Table(
    "users",
    sa.MetaData(),
    sa.Column("id", sa.Integer, primary_key=True),
    sa.Column("name", sa.String(100)),
    sa.Column("first_name", sa.String(50)),
    sa.Column("last_name", sa.String(50)),
)

def upgrade():
    # Schema change first
    op.add_column("users", sa.Column("first_name", sa.String(50)))
    op.add_column("users", sa.Column("last_name", sa.String(50)))

    # Data migration
    bind = op.get_bind()
    rows = bind.execute(sa.select(users.c.id, users.c.name)).fetchall()
    for row in rows:
        parts = row.name.split(" ", 1)
        first = parts[0]
        last = parts[1] if len(parts) > 1 else ""
        bind.execute(
            users.update()
                 .where(users.c.id == row.id)
                 .values(first_name=first, last_name=last)
        )

    # Drop old column after data migrated
    op.drop_column("users", "name")

def downgrade():
    op.add_column("users", sa.Column("name", sa.String(100)))

    bind = op.get_bind()
    rows = bind.execute(sa.select(users.c.id, users.c.first_name, users.c.last_name)).fetchall()
    for row in rows:
        full_name = f"{row.first_name} {row.last_name}".strip()
        bind.execute(users.update().where(users.c.id == row.id).values(name=full_name))

    op.drop_column("users", "first_name")
    op.drop_column("users", "last_name")

Common Mistake: Importing your model classes from your application code into the migration. Don’t:

# WRONG
from myapp.models import User

def upgrade():
    bind = op.get_bind()
    for user in bind.execute(sa.select(User)):
        ...

Your migration runs against a database at the historical state — but your model represents the current state. Six months later when you’ve added more columns, the migration breaks. Define local helper tables in the migration with only the columns that exist at that point.

Fix 8: Multi-Database Setup

For projects with multiple databases (e.g., main DB + analytics DB):

alembic init --template multidb alembic

env.py for multi-DB has separate target_metadata per database:

# alembic/env.py
from alembic import context

# Get DB name from current context
db_names = context.config.get_main_option("databases").split(",")

target_metadatas = {
    "main": main_metadata,
    "analytics": analytics_metadata,
}

# Each DB gets its own URL section in alembic.ini
# alembic.ini
[alembic]
databases = main, analytics

[main]
sqlalchemy.url = postgresql://localhost/main

[analytics]
sqlalchemy.url = postgresql://localhost/analytics
alembic upgrade head   # Upgrades all databases
alembic -n main upgrade head   # Upgrade only main

For lighter multi-DB needs, consider running Alembic separately per database with different config dirs:

alembic -c alembic-main.ini upgrade head
alembic -c alembic-analytics.ini upgrade head

Still Not Working?

Migration in CI/CD

# .github/workflows/migrate.yml
- name: Run migrations
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
  run: |
    alembic upgrade head

    # Verify no pending migrations
    if [ "$(alembic current)" != "$(alembic heads)" ]; then
      echo "Migration check failed"
      exit 1
    fi

Pre-deploy migration check — fail the build if migrations would be needed but weren’t applied.

Migration Squashing

After many migrations, the version history grows unwieldy. To squash:

# 1. Drop and recreate the database from current models
alembic downgrade base
alembic stamp head   # Mark as if all migrations applied (without running)

# 2. Delete old migration files
rm alembic/versions/*

# 3. Generate a single initial migration
alembic revision --autogenerate -m "initial schema"

Pro Tip: Squash migrations rarely (once a year, before major releases). Frequent squashing loses the history that helps debug schema drift. For incremental cleanup, just delete migrations older than a known production version.

Production Migration Patterns

Two key patterns for zero-downtime migrations:

  1. Expand-Contract: Add the new column nullable → deploy code that uses it → make NOT NULL → drop old column. Multiple deploys, no downtime.

  2. Backward-compatible only: Never rename, never narrow types, never drop until ALL code paths stop using the old name.

For Django/SQLAlchemy patterns that affect Alembic migration design, see Django migration conflict and SQLAlchemy not working.

Still Not Working? Migration runs locally, hangs in prod

The migration acquires an AccessExclusiveLock on a busy table and waits behind active queries. Locally, the table is empty and the lock grabs instantly; in production, the table has 50M rows and a steady stream of reads. pg_stat_activity will show the Alembic backend in LockTransactionWait state. Two fixes: set lock_timeout at the start of the migration (op.execute("SET lock_timeout = '5s'")) so it fails fast instead of blocking writes for minutes, and split the migration into a CREATE INDEX CONCURRENTLY-style non-blocking version. Run CONCURRENTLY operations outside any transaction (op.execute("COMMIT") first).

Still Not Working? Multiple services, one database

Three microservices share the same PostgreSQL database, each with its own Alembic version table — except they actually share the same alembic_version table and overwrite each other’s revision pointer. Symptom: deploy of service B “succeeds” but reverts service A’s migration record, so the next service-A deploy thinks it needs to re-run already-applied migrations and fails on duplicate constraints. Fix: give each service its own version table via version_table in env.py (context.configure(..., version_table="alembic_version_service_a")). Long-term, push for one database per service.

Still Not Working? Autogenerate keeps “detecting” the same change

Each run produces the same alter_column op even though you keep applying the generated migration. The model and database really do agree, but a default rendering difference (server-side now() vs CURRENT_TIMESTAMP, or string length defaults) is making the comparison fire. With compare_server_default=True, write a custom compare_server_default callback in env.py that normalizes the default to a canonical string before comparing, or exclude that column via include_object.

Stamp When Importing Existing Database

For an existing production database without Alembic history:

# Generate initial migration matching current schema
alembic revision --autogenerate -m "baseline"

# Mark as already-applied — don't actually run it on prod
alembic stamp head

alembic stamp head writes the revision to the version table without running upgrades — useful for adopting Alembic on a database that already has the right schema.

Combining with FastAPI

For FastAPI integration, do not run alembic upgrade head from inside the app’s lifespan startup — that turns every pod’s startup into a race for the migration lock. Run migrations as a one-shot pre-deploy job (a Kubernetes Job or a CI step) that completes before any app pod boots. Surface PostgreSQL connection failures at the migration step (not the application step) so on-call sees “migration failed to connect” rather than a generic pod crash.

pytest Integration

import pytest
from alembic import command
from alembic.config import Config

@pytest.fixture(scope="session")
def alembic_config():
    config = Config("alembic.ini")
    config.set_main_option("sqlalchemy.url", "postgresql://localhost/test_db")
    return config

@pytest.fixture(scope="session", autouse=True)
def migrate(alembic_config):
    command.upgrade(alembic_config, "head")
    yield
    command.downgrade(alembic_config, "base")   # Clean up after tests

Running command.upgrade followed by command.downgrade per test session catches downgrade bugs that pure-forward CI never exercises — schema asymmetry is one of the most common Alembic regressions.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles