Catch database migration rollback failures before they reach production
Project description
pytest-mrt
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.
pytest-mrt would have caught this before it reached production:
$ mrt check migrations/versions/
Rollback Risk Analysis
╭──────────┬────────┬───────────────────────────┬───────┬──────┬─────────────────────────────────────╮
│ Revision │ Code │ Pattern │ Sev │ Line │ Message │
├──────────┼────────┼───────────────────────────┼───────┼──────┼─────────────────────────────────────┤
│ 042 │ MRT201 │ DROP COLUMN in upgrade │ error │ 18 │ op.drop_column('users', 'phone') — │
│ │ │ │ │ │ column data is permanently lost │
│ │ │ │ │ │ even if downgrade re-adds the column│
╰──────────┴────────┴───────────────────────────┴───────┴──────┴─────────────────────────────────────╯
1 error(s), 0 warning(s)
Non-invasive — installs in 2 minutes, zero changes to your existing tests.
What it does
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.
Install
pip install pytest-mrt
Setup (2 minutes)
Add this to conftest.py:
# conftest.py
import os
from pytest_mrt import MRTConfig
def pytest_configure(config):
config._mrt_config = MRTConfig(
alembic_ini="alembic.ini",
db_url=os.environ.get("TEST_DATABASE_URL", "sqlite:///test.db"),
)
That's it. Run pytest and 6 safety tests appear automatically — no test files needed:
PASSED test_mrt_single_head - Migration history has exactly one head
PASSED test_mrt_upgrade - alembic upgrade head completes without error
PASSED test_mrt_downgrade_base - alembic downgrade base then re-upgrade completes cleanly
PASSED test_mrt_up_down_consistency - Every migration is safely reversible
PASSED test_mrt_static_no_errors - Zero static analysis errors in all migration files
PASSED test_mrt_schema_matches_models- Database schema matches ORM models after upgrade
Want to write custom rollback tests? Use the
mrtfixture — just add it as a parameter to any test function, no import needed:def test_migration_003(mrt): mrt.assert_reversible("abc1234")
Static analysis (no database needed)
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)
What gets caught
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
Databases
| 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] # pymssql
Auto-fix missing reverse operations
mrt 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 --apply
Django — 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 --apply
For 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 --yes
pre-commit integration
Add to .pre-commit-config.yaml to run mrt check automatically before every push:
# Alembic
- repo: https://github.com/croc100/pytest-mrt
rev: v1.4.0
hooks:
- id: mrt-check
args: [alembic/versions/]
# Django
- repo: https://github.com/croc100/pytest-mrt
rev: v1.4.0
hooks:
- id: mrt-check
args: [myapp/migrations/]
Update rev to the latest release tag. Run pre-commit autoupdate to keep it current.
Incremental CI — --since
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 (filename without .py)
mrt check myapp/migrations/ --since myapp.0010_add_email
Pass the last migration on the base branch; only PR-new migrations are scanned.
When
--sinceis active, graph-level checks (orphan detection, data-hole analysis) are skipped. Run without--sinceperiodically for full coverage. See the CLI reference for the full format specification.
CI/CD integration
Drop 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/ --strict
Full examples for GitHub Actions, GitLab CI, Jenkins, and pre-commit hooks are in examples/ci-integration/.
Docker
Run tests locally against PostgreSQL or MySQL without installing anything:
docker compose run test-postgres
docker compose run test-mysql
See docker-compose.yml for the full configuration.
Performance
| 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.
Suppress known risks (v1.2.0)
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: MRT103
To suppress all MRT warnings on a line:
op.drop_column("users", "legacy_col") # noqa
Legacy syntax # mrt: ignore is still supported for backward compatibility.
How it compares
| pytest-mrt | pytest-alembic | alembic check | django-test-migrations | |
|---|---|---|---|---|
| Static analysis (no DB required) | ✅ 44 patterns | ❌ | ❌ | ❌ |
| Dynamic rollback testing | ✅ | ✅ | ❌ | ✅ |
| Data survival check (seeds rows, verifies after rollback) | ✅ | ❌ schema only | ❌ | ❌ |
| Django support | ✅ | ❌ | ❌ | ✅ |
Auto-fix (mrt fix) |
✅ | ❌ | ❌ | ❌ |
| Pre-commit hook | ✅ | ❌ | ❌ | ❌ |
Inline suppression (# noqa: MRTxxx) |
✅ | ❌ | ❌ | ❌ |
The key difference from pytest-alembic: pytest-mrt seeds actual rows before each rollback and verifies they survive. A migration that reverses the schema cleanly but silently destroys data will pass pytest-alembic and fail pytest-mrt.
What's new in v1.4.0
mrt check --format json/html— structured JSON output for CI tooling; self-contained HTML safety reportmrt check --watch— re-runs automatically whenever a migration file changesmrt check --min-revision— skip revisions older than a configured floor (mirrorsMRTConfig.minimum_downgrade_revision)mrt fix --applybatch mode — fix all auto-fixable migrations at once;--dry-runpreviews without writing- Django squashmigrations detection — MRT601/MRT602 catch unsafe
RunPythonin squashed migrations minimum_downgrade_revisionin dynamic tests — floor now respected by themrtfixturecheck_all(), not just static analysis
Changelog
See CHANGELOG.md for the full release history.
Documentation
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
Sponsorship
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
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytest_mrt-1.4.1.tar.gz.
File metadata
- Download URL: pytest_mrt-1.4.1.tar.gz
- Upload date:
- Size: 261.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
02b8b63a1d5be2684905e81ee3b1095edf7163f5ae64b2b0079304f8565d098c
|
|
| MD5 |
22d2b9dfd56287ee4a40816a1d867e15
|
|
| BLAKE2b-256 |
63adabb6402ce96f506e41325f91481ad933cc0a16666852d8d2371f0334618b
|
Provenance
The following attestation bundles were made for pytest_mrt-1.4.1.tar.gz:
Publisher:
publish.yml on croc100/pytest-mrt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_mrt-1.4.1.tar.gz -
Subject digest:
02b8b63a1d5be2684905e81ee3b1095edf7163f5ae64b2b0079304f8565d098c - Sigstore transparency entry: 1782114335
- Sigstore integration time:
-
Permalink:
croc100/pytest-mrt@02f86e8e8391584a89509a976f73f4279f40c399 -
Branch / Tag:
refs/tags/v1.4.1 - Owner: https://github.com/croc100
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@02f86e8e8391584a89509a976f73f4279f40c399 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pytest_mrt-1.4.1-py3-none-any.whl.
File metadata
- Download URL: pytest_mrt-1.4.1-py3-none-any.whl
- Upload date:
- Size: 81.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1e2a55a66e9d3e9145dac999ec101057f7eeaa7750e2b248a43d2f9d2a443d01
|
|
| MD5 |
56da5052e46014188d7ffa275ebb5f5f
|
|
| BLAKE2b-256 |
f95185e3253d85adfa2099cce4d59c484c07de7664b42a4868080639efebd565
|
Provenance
The following attestation bundles were made for pytest_mrt-1.4.1-py3-none-any.whl:
Publisher:
publish.yml on croc100/pytest-mrt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_mrt-1.4.1-py3-none-any.whl -
Subject digest:
1e2a55a66e9d3e9145dac999ec101057f7eeaa7750e2b248a43d2f9d2a443d01 - Sigstore transparency entry: 1782114565
- Sigstore integration time:
-
Permalink:
croc100/pytest-mrt@02f86e8e8391584a89509a976f73f4279f40c399 -
Branch / Tag:
refs/tags/v1.4.1 - Owner: https://github.com/croc100
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@02f86e8e8391584a89509a976f73f4279f40c399 -
Trigger Event:
release
-
Statement type: