Alembic’s most powerful feature is autogenerate — comparing your SQLAlchemy models to the current database schema and generating a migration script that bridges the gap. It detects added tables, dropped tables, added columns, dropped columns, type changes, and index changes. It does not detect column renames (sees a drop + add), changes to stored procedures, or triggers. Understanding the limits of autogenerate prevents the most dangerous Alembic mistake: applying a migration that silently drops a column because Alembic misread a rename as a delete-and-create.
Generating Autogenerated Migrations
# Generate a migration by comparing models to the current DB schema
alembic revision --autogenerate -m "add_tags_table"
# The generated file appears in alembic/versions/
# Example filename: abc123def456_add_tags_table.py
# Review BEFORE applying:
cat alembic/versions/abc123def456_add_tags_table.py
# Apply after review:
alembic upgrade head
# Example generated migration: alembic/versions/abc123_add_tags_table.py
"""add tags table
Revision ID: abc123def456
Revises: xyz789abc000
Create Date: 2025-08-06 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "abc123def456"
down_revision = "xyz789abc000" # points to the previous migration
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("slug"),
)
op.create_index("ix_tags_id", "tags", ["id"], unique=False)
def downgrade() -> None:
op.drop_index("ix_tags_id", table_name="tags")
op.drop_table("tags")
op.alter_column(table, "old_name", new_column_name="new_name").compare_type=True in env.py makes Alembic detect column type changes (e.g., VARCHAR to TEXT). Setting compare_server_default=True detects changes to server_default values. Without these settings, common schema changes go undetected. Also consider include_schemas=True if you use multiple PostgreSQL schemas.op.drop_column() that you did not expect. This can happen when you rename a column in Python (which Alembic reads as “old column deleted, new column added”) or when a column exists in the database but is missing from the model (perhaps from a previous manual SQL change). Always read the generated downgrade() function to understand what would happen if you rolled back — if it recreates a column that upgrade() drops, the round-trip should be safe.Writing Manual Migrations
# Generate an empty migration for manual SQL operations
alembic revision -m "add_full_text_search_index"
# alembic/versions/def456_add_full_text_search_index.py
"""add full text search index
Revision ID: def456ghi789
Revises: abc123def456
Create Date: 2025-08-06 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "def456ghi789"
down_revision = "abc123def456"
def upgrade() -> None:
# ── Add a generated tsvector column ──────────────────────────────────────
op.execute("""
ALTER TABLE posts
ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(body, '')), 'B')
) STORED
""")
# ── Create GIN index concurrently (non-blocking on production) ───────────
# Note: CONCURRENTLY cannot run inside a transaction — use execute_if
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_posts_search "
"ON posts USING GIN (search_vector)"
)
def downgrade() -> None:
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_posts_search")
op.drop_column("posts", "search_vector")
What Autogenerate Can and Cannot Detect
| Change | Auto-detected? | Action Required |
|---|---|---|
| Add table | ✅ Yes | None |
| Drop table | ✅ Yes | Review carefully! |
| Add column | ✅ Yes | None |
| Drop column | ✅ Yes | Review carefully! |
| Column type change | ✅ With compare_type=True | Set compare_type=True |
| Add index | ✅ Yes | None |
| Column rename | ❌ No (drop + add) | Write manual op.alter_column() |
| Table rename | ❌ No (drop + create) | Write manual op.rename_table() |
| Stored procedure | ❌ No | Write manual op.execute() |
| Trigger | ❌ No | Write manual op.execute() |
| CHECK constraint | Partial | Verify manually |
Common Mistakes
Mistake 1 — Applying migration without reviewing it
❌ Wrong — blindly applying autogenerated migration:
alembic revision --autogenerate -m "changes" && alembic upgrade head
# May drop columns or tables if Alembic misread the intent!
✅ Correct — review the generated file before applying:
alembic revision --autogenerate -m "changes"
# READ the generated file carefully
alembic upgrade head # ✓ only after review
Mistake 2 — Using CONCURRENTLY inside a transaction
❌ Wrong — CREATE INDEX CONCURRENTLY cannot run inside a transaction:
def upgrade():
op.create_index("idx_posts_status", "posts", ["status"]) # wraps in transaction
# For CONCURRENTLY, use op.execute() directly
✅ Correct — use op.execute() and mark as non-transactional if needed:
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY idx_posts_status ON posts(status)") # ✓
Mistake 3 — Empty downgrade() function
❌ Wrong — cannot roll back:
def downgrade() -> None:
pass # can never roll back this migration!
✅ Correct — always implement downgrade():
def downgrade() -> None:
op.drop_column("posts", "search_vector") # ✓ reversible
Quick Reference
| Task | Command |
|---|---|
| Autogenerate migration | alembic revision --autogenerate -m "description" |
| Empty migration | alembic revision -m "description" |
| Show generated SQL | alembic upgrade head --sql |
| Rename column | op.alter_column(table, "old", new_column_name="new") |
| Raw SQL in migration | op.execute("ALTER TABLE ...") |
| Add constraint | op.create_unique_constraint("name", "table", ["col"]) |