Best Practices
Guidelines for writing safe, rollback-friendly database migrations.
Migration design principles
1. Every upgrade() must have a matching downgrade()
The most common cause of rollback failures is a downgrade() that does nothing:
2. Test rollback locally before code review
# Before opening a PR:
mrt check alembic/versions/
pytest tests/test_migrations.py -k "test_latest" -v
Catching rollback failures locally is faster than in CI.
3. One logical change per migration
Splitting migrations makes rollbacks surgical. A migration that creates a table, backfills data, and adds an index is harder to roll back safely than three separate migrations.
001_create_users.py ← schema only
002_backfill_user_status.py ← data only (mark as skip if irreversible)
003_add_users_email_index.py ← index only
4. Document intentionally irreversible migrations
Not every migration can or should be reversible. When you skip one, document why:
# conftest.py
MRTConfig(
skip={
"1a2b3c4d": (
"Backfills 'status' from 'is_active'. "
"Old column dropped in 002. "
"Rollback handled by ops team via snapshot restore. "
"Reviewed by: @alice, 2025-01-15. See ADR-023."
)
}
)
Patterns to avoid
Adding NOT NULL columns to existing tables
# Dangerous — will fail if any existing rows exist
def upgrade():
op.add_column("users", sa.Column("score", sa.Integer(), nullable=False))
# Safe
def upgrade():
op.add_column("users", sa.Column("score", sa.Integer(), nullable=True))
# Backfill in a separate migration or application code
# Then in a later migration: op.alter_column("users", "score", nullable=False)
Dropping columns before the application stops reading them
Follow the expand-contract pattern:
- Expand: add new column (application reads old column)
- Migrate: application reads both columns
- Contract: drop old column (application only reads new column)
Never drop a column in the same deployment where you remove the code that uses it.
Using op.execute() for data changes without a reverse
# Dangerous
def upgrade():
op.execute("UPDATE users SET role = 'member' WHERE role IS NULL")
def downgrade():
pass # data is lost
# Safe
def upgrade():
op.execute("UPDATE users SET role = 'member' WHERE role IS NULL")
def downgrade():
op.execute("UPDATE users SET role = NULL WHERE role = 'member'")
# Or: mark this as skip if the data change is intentional and irreversible
Creating indexes inside a transaction (PostgreSQL)
# Dangerous — locks the table for the duration on large tables
def upgrade():
op.create_index("ix_users_email", "users", ["email"])
# Safe on PostgreSQL — use CONCURRENTLY
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_email ON users (email)")
def downgrade():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_users_email")
When using CONCURRENTLY, the migration must run outside a transaction block. In Alembic, set migration_context_configure(transaction_per_migration=True) or use a raw connection.
Performance
Testing large migration chains
If you have hundreds of migrations, assert_all_reversible() can be slow. Options:
Test only recent migrations:
def test_recent_migrations(mrt):
# Only test migrations from the last 30 revisions
results = mrt.check_all()
recent = results[-30:]
failed = [r for r in recent if not r.passed]
assert not failed
Test only the migration being added in this PR:
import os
def test_new_migration(mrt):
revision = os.environ.get("NEW_REVISION")
if not revision:
pytest.skip("No NEW_REVISION set — run full suite instead")
mrt.upgrade(revision.split(":")[0]) # advance to predecessor
result = mrt.check_revision(revision)
assert result.passed, result.failure_summary()
Run different scopes in CI:
# On PR: test only new migration
- run: pytest tests/test_migrations.py -k "test_new" -v
# On merge to main: test full chain (weekly)
- run: pytest tests/test_migrations.py -k "test_all" -v
Parallelizing across databases
Use pytest-xdist with separate database URLs:
pytest tests/ -n 2 \
--db1=postgresql://localhost/test_db_1 \
--db2=postgresql://localhost/test_db_2
Continuous integration
Block merges on static errors
Add mrt check --strict as a required status check. It requires no database and completes in seconds.
# .github/workflows/migration-safety.yml
- name: Static analysis
run: mrt check alembic/versions/ --strict
Separate static and dynamic checks
Static analysis is fast (< 1s) and requires no database. Run it on every commit. Dynamic rollback testing requires a database and takes longer — run it on PRs that touch migrations.
Cache test database state
For long migration chains, use a pre-seeded database snapshot:
- name: Restore DB snapshot
run: pg_restore -d $TEST_DB snapshots/migrations_base.dump
- name: Run rollback tests
run: pytest tests/test_migrations.py -v
Django-specific guidance
Always provide reverse_code for RunPython
def populate_status(apps, schema_editor):
User = apps.get_model("myapp", "User")
User.objects.filter(status=None).update(status="active")
def depopulate_status(apps, schema_editor):
User = apps.get_model("myapp", "User")
User.objects.filter(status="active").update(status=None)
class Migration(migrations.Migration):
operations = [
migrations.RunPython(populate_status, depopulate_status),
]
Use atomic = False for index operations
class Migration(migrations.Migration):
atomic = False # Required for CONCURRENTLY
operations = [
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["email"], name="idx_email"),
),
]
Mark truly irreversible migrations explicitly
class Migration(migrations.Migration):
operations = [
migrations.RunPython(
forward_func,
migrations.RunPython.noop, # Explicitly mark as no-op reverse
),
]
Then add this revision to MRTConfig.skip with a documented reason.
Writing effective skip entries
A good skip entry answers: - What data change happened - Why it can't be reversed - How to recover in a rollback scenario - Who reviewed and approved it - When it was reviewed
skip={
"f8a2b1c3": (
"WHAT: Backfills 'uuid' column from 'id' for all users. "
"WHY: UUID values are randomly generated — cannot be deterministically reversed. "
"HOW TO RECOVER: Restore from DB snapshot tagged 'pre-uuid-migration-2025-01-15'. "
"REVIEWED BY: @alice (backend lead), @bob (SRE). "
"DATE: 2025-01-15. See ADR-031."
)
}
Setting a permanent rollback floor
Once a project has a long migration history, testing every migration from the beginning can be slow and unnecessary. Use minimum_downgrade_revision to skip everything at or before a known-safe baseline:
# conftest.py
config._mrt_config = MRTConfig(
db_url=os.environ["TEST_DATABASE_URL"],
minimum_downgrade_revision="a1b2c3d4", # Alembic: revision ID
# minimum_downgrade_revision="myapp.0050_baseline", # Django: app_label.migration_name
)
Migrations at or before the floor are advanced through (to keep the DB state consistent) but not tested. Migrations after the floor are tested as normal.
This is different from skip:
- skip exempts specific migrations from testing (use for genuinely irreversible data migrations)
- minimum_downgrade_revision sets a floor — everything below it is treated as "already validated, no need to re-test"
The same floor applies to both mrt check (static analysis) and check_all() (dynamic rollback tests). The CLI equivalent is mrt check --min-revision <rev>.