A pytest plugin that catches database migration rollback failures before they reach production.
alembic downgrade -1 ran clean. No errors. Your monitoring went green.
But the users' phone numbers are gone. The column came back. The data didn't.
Most tools verify that migrations run without errors.
pytest-mrt verifies that your data survives a rollback.
It seeds real rows before each migration, rolls back, and checks nothing was lost. It also statically scans migration files for 44 known dangerous patterns across both Alembic and Django migrations.
pip install pytest-mrt1. Create conftest.py in your project root:
# conftest.py
import os
from pytest_mrt import MRTConfig
def pytest_configure(config):
config._mrt_config = MRTConfig(
alembic_ini="alembic.ini", # path to your alembic.ini
db_url=os.environ.get("TEST_DATABASE_URL", "sqlite:///test.db"), # test database
)2. Write a test:
# tests/test_migrations.py
def test_migrations_are_safe(mrt):
mrt.assert_all_reversible()3. Run:
pytest tests/test_migrations.py -s
mrtis a pytest fixture — just add it as a parameter and it works. No import needed in test files.
mrt check migrations/versions/╭──────────┬──────────────────────────┬─────────┬──────┬─────────┬────────────────────────────────────╮
│ Revision │ Pattern │ Sev │ Line │ Code │ Message │
├──────────┼──────────────────────────┼─────────┼──────┼─────────┼────────────────────────────────────┤
│ 004 │ DROP COLUMN in upgrade │ error │ 12 │ MRT103 │ Data permanently lost on rollback │
│ 005 │ No-op downgrade │ error │ 8 │ MRT102 │ downgrade() does nothing │
│ 006 │ INDEX without CONCURR. │ warning │ 19 │ MRT207 │ Locks table during index build │
╰──────────┴──────────────────────────┴─────────┴──────┴─────────┴────────────────────────────────────╯
2 error(s), 1 warning(s)
Errors (will cause data loss or a broken rollback):
op.drop_column()in upgrade — data is gone even if downgrade re-adds the columnop.drop_table()in upgrade — all rows permanently lostTRUNCATEin migrationdef downgrade(): pass— rollback silently does nothing- No
downgrade()function rename_table/rename_columnwithout reverseDROP VIEWwithout recreating in downgradeALTER TYPE ... ADD VALUE(PostgreSQL ENUM) — can't roll back once rows use the new value- Add column + migrate data + drop original in one migration
Warnings (review before deploying):
NOT NULLwithoutserver_default- Column type change
- Raw
op.execute()/context.execute()without reverse op.execute(sa.text(...))— SQL insidesa.text()wrapper now fully analyzedop.bulk_insert()without correspondingDELETEin downgrade- Bulk
UPDATEwithout a reverseUPDATEin downgrade ON DELETE CASCADEaddedCREATE INDEXwithoutCONCURRENTLY(PostgreSQL)ADD COLUMNwithDEFAULTon large tablesCREATE UNIQUE CONSTRAINTon existing dataDROP INDEXwithout recreatingDROP CONSTRAINTwithout recreatingALTER SEQUENCE/setvalNOT NULLvia raw SQL without reverseNOT NULLwithout restoringnullablein downgrade
| Static analysis | Dynamic verification | |
|---|---|---|
| PostgreSQL | Yes | Yes |
| SQLite | Yes | Yes |
| MySQL / MariaDB | Yes | Yes |
| Oracle | Yes | Yes |
| SQL Server | Yes | Yes |
pip install pytest-mrt[mysql] # PyMySQL
pip install pytest-mrt[oracle] # python-oracledb
pip install pytest-mrt[mssql] # pymssqlmrt fix generates missing reverse operations for both Alembic and Django migrations.
Alembic — generates a missing or stub downgrade():
mrt fix migrations/versions/0042_drop_phone.py --applyDjango — adds reverse_sql, reverse_code, and full backup/restore scaffolding for data-loss operations (RemoveField, DeleteModel):
mrt fix myapp/migrations/0042_remove_user_phone.py --applyFor RemoveField and DeleteModel, the generated code backs up data to a _mrt_backups table before the migration runs, and restores it on rollback. After deployment is confirmed stable, clean up the backup rows:
mrt clean-backups --db $DATABASE_URL
mrt clean-backups --db $DATABASE_URL --label 0042_remove_user_phone --yesAdd to .pre-commit-config.yaml to run mrt check automatically before every push:
# Alembic
- repo: https://github.com/croc100/pytest-mrt
rev: v1.3.1
hooks:
- id: mrt-check
args: [alembic/versions/]
# Django
- repo: https://github.com/croc100/pytest-mrt
rev: v1.3.1
hooks:
- id: mrt-check
args: [myapp/migrations/]Update rev to the latest release tag. Run pre-commit autoupdate to keep it current.
Check only migrations added since a given revision. Keeps CI fast on large codebases:
# Alembic — pass a revision ID
mrt check migrations/versions/ --since a1b2c3d4
# Django — pass app_label.migration_name
mrt check myapp/migrations/ --since myapp.0010_add_emailDrop mrt check into any pipeline as a pre-deploy gate:
# GitHub Actions — blocks merge if unsafe migrations are detected
- name: Migration safety check
run: mrt check alembic/versions/ --strictFull examples for GitHub Actions, GitLab CI, Jenkins, and pre-commit hooks are in examples/ci-integration/.
Run tests locally against PostgreSQL or MySQL without installing anything:
docker compose run test-postgres
docker compose run test-mysqlSee docker-compose.yml for the full configuration.
| 10 migrations | 50 migrations | 100 migrations | |
|---|---|---|---|
mrt check (static, no DB) |
22 ms | 108 ms | 216 ms |
mrt fixture (SQLite) |
0.33 s | 4.3 s | 15.6 s |
Safe to run mrt check on every commit. Dynamic suite fits comfortably for projects up to ~200 migrations.
For larger codebases, use MRTConfig(skip={...}) to exclude already-reviewed revisions.
See benchmarks for methodology and PostgreSQL/MySQL numbers.
pytest-mrt automatically injects 6 safety tests into your suite when the mrt fixture is configured — no test files needed:
| Test | What it checks |
|---|---|
test_mrt_single_head |
Migration history has exactly one head |
test_mrt_upgrade |
alembic upgrade head completes without error |
test_mrt_downgrade_base |
alembic downgrade base then re-upgrade completes cleanly |
test_mrt_up_down_consistency |
Every migration is safely reversible (per-revision rollback) |
test_mrt_static_no_errors |
Zero static analysis errors in all migration files |
test_mrt_schema_matches_models |
Database schema matches ORM models after upgrade (requires target_metadata) |
To opt out of specific tests, use MRTConfig(skip_default_tests={...}).
Use # noqa: MRTxxx on any line to suppress a specific warning — the same convention as ruff and flake8:
def upgrade():
op.drop_column("users", "phone") # noqa: MRT103To suppress all MRT warnings on a line:
op.drop_column("users", "legacy_col") # noqaLegacy syntax # mrt: ignore is still supported for backward compatibility.
- Error message quality — config errors show once at session start instead of repeating for every test
mrt initfix — generatedconftest.pynow has correctly quoteddb_urlmrt check --sincevalidation — warns and exits when the revision matches no migrations- Django
mrt fix— unsupported operations (AddFieldNOT NULL,RenameField, etc.) now show a clear manual-fix guide instead of silent no-op - Django downgrade fix — rollback with branch migrations no longer touches unrelated sibling branches
See CHANGELOG.md for the full release history.
Full docs at croc100.github.io/pytest-mrt
- Getting started (step-by-step)
- All 44 patterns explained
- CLI & fixture reference
- Detection accuracy report — what each pattern catches and doesn't catch
- API reference — stable public API
- FAQ — timeouts, large codebases, Django, error handling
pytest-mrt is MIT-licensed and free to use. If it saves you from a production incident, consider sponsoring development:
Sponsorship directly funds:
- New pattern development (Oracle, SQL Server, more Django patterns)
- Maintained compatibility with new Alembic and SQLAlchemy releases
MIT