Fix: Alembic Not Working — Autogenerate Missing Changes, Multiple Heads, and Migration Conflicts
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 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.
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 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.
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 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.
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 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, 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 testsRunning 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.
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.