Fix: Alembic Not Working — Autogenerate Missing Changes, Multiple Heads, and Migration Conflicts
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 addedOr 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 headsOr 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 changedOr async engine support breaks:
NotImplementedError: This is a synchronous APIAlembic 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 alembicResulting 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.pyConfigure database URL in alembic.ini:
# alembic.ini
sqlalchemy.url = postgresql://user:pass@localhost/mydbBetter — 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.pyConnect 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.metadataCommon 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 revisionInspect state:
alembic current # Show current revision
alembic history # Show all revisions
alembic heads # Show current head(s)
alembic show abc123 # Show specific revision detailsA 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
ENUMtypes) - 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 presentTwo developers created migrations from the same parent in parallel:
A (parent)
/ \
B C ← Two headsInspect:
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 headalembic upgrade head # Now worksAvoid 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 tableAdding 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 APIIf 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 alembicenv.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/analyticsalembic upgrade head # Upgrades all databases
alembic -n main upgrade head # Upgrade only mainFor 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 headStill 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
fiPre-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:
Expand-Contract: Add the new column nullable → deploy code that uses it → make NOT NULL → drop old column. Multiple deploys, no downtime.
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 headalembic 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 testsFor pytest fixture patterns with database state, see pytest fixture not found.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Peewee Not Working — Connection Pooling, Field Errors, and Migration Setup
How to fix Peewee errors — OperationalError database is locked, connection already open, field type mismatch, model meta database missing, N+1 queries, and peewee-migrate setup.
Fix: psycopg Not Working — psycopg2 to psycopg3 Migration, Connection Pool, and Async Errors
How to fix psycopg errors — psycopg2 to psycopg 3 import migration, connection pool setup, row factory tuple vs dict, COPY protocol changes, async psycopg pool, server-side cursor confusion, and binary mode performance.
Fix: SQLModel Not Working — table=True Confusion, Relationship Loading, and Session Errors
How to fix SQLModel errors — table not created without table=True, relationship not eager-loaded MissingGreenlet, AttributeError on lazy attribute, mixing Pydantic and Table classes, Optional vs default None, and async session setup.
Fix: asyncpg Not Working — Connection Pool, Prepared Statements, and Transaction Errors
How to fix asyncpg errors — connection refused localhost 5432, pool exhausted timeout, prepared statement does not exist, type codec not registered, JSON automatic conversion, and transaction rollback on exception.