Skip to content

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

FixDevs ·

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.

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.

For asyncpg patterns in async SQLAlchemy applications, see asyncpg not working.

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.

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 patterns, see FastAPI dependency injection error. For PostgreSQL connection issues that surface during migration, see PostgreSQL connection refused.

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

For pytest fixture patterns with database state, see pytest fixture not found.

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