| name | database-migrations |
| description | Automatically applies when working with database migrations. Ensures proper Alembic patterns, upgrade/downgrade scripts, data migrations, rollback safety, and migration testing. |
| category | python |
Database Migration Patterns
When managing database migrations, follow these patterns for safe, reversible schema changes.
Trigger Keywords: migration, alembic, database schema, upgrade, downgrade, migrate, schema change, DDL, database version, revision
Agent Integration: Used by backend-architect, database-engineer, data-engineer
✅ Correct Pattern: Alembic Setup
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.models import Base
from app.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine.
"""
url = settings.database_url
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.
Creates an Engine and associates a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = settings.database_url
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Create Migration
"""
Create migration with proper naming and structure.
Command:
alembic revision --autogenerate -m "add_user_email_index"
Revision ID: abc123
Revises: xyz456
Create Date: 2025-01-15 10:30:00
"""
from alembic import op
import sqlalchemy as sa
from typing import Sequence, Union
revision: str = 'abc123'
down_revision: Union[str, None] = 'xyz456'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""
Apply migration.
Add index on users.email for faster lookups.
"""
op.create_index(
'ix_users_email',
'users',
['email'],
unique=False
)
def downgrade() -> None:
"""
Revert migration.
Remove index on users.email.
"""
op.drop_index('ix_users_email', table_name='users')
Safe Column Additions
"""Add user phone number column"""
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
"""Add phone column with nullable default."""
op.add_column(
'users',
sa.Column(
'phone',
sa.String(20),
nullable=True
)
)
op.execute(
"""
UPDATE users
SET phone = ''
WHERE phone IS NULL
"""
)
def downgrade() -> None:
"""Remove phone column."""
op.drop_column('users', 'phone')
Safe Column Modifications
"""Increase email column length"""
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
"""
Increase email length from 255 to 500.
Safe for PostgreSQL (no table rewrite).
"""
with op.batch_alter_table('users') as batch_op:
batch_op.alter_column(
'email',
type_=sa.String(500),
existing_type=sa.String(255),
existing_nullable=False
)
def downgrade() -> None:
"""
Decrease email length back to 255.
WARNING: May fail if existing data exceeds 255 chars.
"""
op.execute(
"""
SELECT COUNT(*)
FROM users
WHERE LENGTH(email) > 255
"""
)
with op.batch_alter_table('users') as batch_op:
batch_op.alter_column(
'email',
type_=sa.String(255),
existing_type=sa.String(500),
existing_nullable=False
)
Data Migration
"""Migrate user status enum values"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
def upgrade() -> None:
"""
Migrate status from 'active'/'inactive' to 'enabled'/'disabled'.
Two-phase approach:
1. Add new column
2. Migrate data
3. Drop old column (in next migration)
"""
op.add_column(
'users',
sa.Column(
'account_status',
sa.Enum('enabled', 'disabled', 'suspended', name='account_status'),
nullable=True
)
)
connection = op.get_bind()
connection.execute(
text("""
UPDATE users
SET account_status = CASE
WHEN status = 'active' THEN 'enabled'
WHEN status = 'inactive' THEN 'disabled'
ELSE 'disabled'
END
""")
)
op.alter_column('users', 'account_status', nullable=False)
def downgrade() -> None:
"""Rollback account_status column."""
op.drop_column('users', 'account_status')
Foreign Key Changes
"""Add foreign key to orders table"""
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
"""
Add foreign key constraint.
Ensure referential integrity.
"""
op.create_index(
'ix_orders_user_id',
'orders',
['user_id']
)
op.create_foreign_key(
'fk_orders_user_id',
'orders',
'users',
['user_id'],
['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
"""Remove foreign key constraint."""
op.drop_constraint(
'fk_orders_user_id',
'orders',
type_='foreignkey'
)
op.drop_index('ix_orders_user_id', table_name='orders')
Complex Table Changes
"""Split user table into users and profiles"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
def upgrade() -> None:
"""
Split users table into users (auth) and profiles (data).
Multi-step migration:
1. Create new profiles table
2. Copy data
3. Add foreign key
4. Drop columns from users
"""
op.create_table(
'profiles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('first_name', sa.String(100), nullable=True),
sa.Column('last_name', sa.String(100), nullable=True),
sa.Column('bio', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
connection = op.get_bind()
connection.execute(
text("""
INSERT INTO profiles (user_id, first_name, last_name, bio, created_at)
SELECT id, first_name, last_name, bio, created_at
FROM users
""")
)
op.create_foreign_key(
'fk_profiles_user_id',
'profiles',
'users',
['user_id'],
['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
"""
Reverse table split.
WARNING: Complex rollback.
"""
op.add_column('users', sa.Column('first_name', sa.String(100)))
op.add_column('users', sa.Column('last_name', sa.String(100)))
op.add_column('users', sa.Column('bio', sa.Text()))
connection = op.get_bind()
connection.execute(
text("""
UPDATE users
SET first_name = p.first_name,
last_name = p.last_name,
bio = p.bio
FROM profiles p
WHERE users.id = p.user_id
""")
)
op.drop_table('profiles')
Migration Testing
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from app.config import settings
@pytest.fixture
def alembic_config():
"""Create Alembic configuration."""
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", settings.test_database_url)
return config
@pytest.fixture
def empty_database():
"""Create empty test database."""
engine = create_engine(settings.test_database_url)
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
yield engine
engine.dispose()
def test_migrations_upgrade_downgrade(alembic_config, empty_database):
"""
Test migrations can upgrade and downgrade.
Ensures all migrations are reversible.
"""
command.upgrade(alembic_config, "head")
inspector = inspect(empty_database)
tables = inspector.get_table_names()
assert "users" in tables
assert "alembic_version" in tables
command.downgrade(alembic_config, "base")
inspector = inspect(empty_database)
tables = inspector.get_table_names()
assert "users" not in tables
def test_migration_data_integrity(alembic_config, empty_database):
"""
Test data migration preserves data integrity.
Insert test data, run migration, verify data.
"""
command.upgrade(alembic_config, "abc123")
with empty_database.begin() as conn:
conn.execute(
text("INSERT INTO users (email, status) VALUES (:email, :status)"),
{"email": "test@example.com", "status": "active"}
)
command.upgrade(alembic_config, "abc124")
with empty_database.begin() as conn:
result = conn.execute(
text("SELECT account_status FROM users WHERE email = :email"),
{"email": "test@example.com"}
)
row = result.fetchone()
assert row[0] == "enabled"
def test_migration_rollback_safety(alembic_config, empty_database):
"""
Test rollback doesn't lose data.
Verify downgrade preserves critical data.
"""
command.upgrade(alembic_config, "head")
with empty_database.begin() as conn:
conn.execute(
text("INSERT INTO users (email) VALUES (:email)"),
{"email": "test@example.com"}
)
command.downgrade(alembic_config, "-1")
with empty_database.begin() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM users WHERE email = :email"),
{"email": "test@example.com"}
)
count = result.scalar()
assert count == 1
❌ Anti-Patterns
def upgrade():
op.add_column('users', sa.Column('phone', sa.String(), nullable=False))
def upgrade():
op.add_column('users', sa.Column('phone', sa.String(), nullable=True))
op.execute("UPDATE users SET phone = '' WHERE phone IS NULL")
def downgrade():
pass
def downgrade():
op.drop_column('users', 'phone')
def upgrade():
op.add_column('users', sa.Column('full_name', sa.String()))
op.execute("UPDATE users SET full_name = first_name || ' ' || last_name")
op.drop_column('users', 'first_name')
op.drop_column('users', 'last_name')
def upgrade():
op.create_foreign_key(None, 'orders', 'users', ['user_id'], ['id'])
def upgrade():
op.create_foreign_key('fk_orders_user_id', 'orders', 'users', ['user_id'], ['id'])
Best Practices Checklist
- ✅ Use descriptive migration names
- ✅ Always implement downgrade()
- ✅ Add columns as nullable first
- ✅ Use batch operations for SQLite
- ✅ Name all constraints explicitly
- ✅ Test migrations up and down
- ✅ Split complex changes into multiple migrations
- ✅ Create indexes before foreign keys
- ✅ Use transactions for data migrations
- ✅ Document breaking changes
- ✅ Test with production-like data volumes
- ✅ Keep migrations idempotent when possible
Auto-Apply
When creating migrations:
- Use
alembic revision --autogenerate -m "descriptive_name"
- Review generated migration carefully
- Implement proper downgrade()
- Add columns as nullable initially
- Split data migrations from schema migrations
- Name all constraints explicitly
- Write tests for complex migrations
- Document breaking changes in docstring
Related Skills
query-optimization - For index creation
type-safety - For type hints in migrations
pytest-patterns - For migration testing
structured-errors - For error handling
docstring-format - For migration documentation