Catch database migration rollback failures before they reach production
Project description
pytest-mrt
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.
This is what pytest-mrt exists to prevent.
What it does
Most tools verify that migrations run without errors. pytest-mrt verifies that your data survives a rollback — by seeding real rows before each migration, rolling back, and checking nothing was lost.
It also statically scans your migration files for 24 known dangerous patterns before you touch a database at all.
pip install pytest-mrt
Quickstart
# conftest.py
from pytest_mrt import MRTConfig
def pytest_configure(config):
config._mrt_config = MRTConfig(
alembic_ini="alembic.ini",
db_url=os.environ["TEST_DATABASE_URL"],
)
# test_migrations.py
def test_migrations_are_safe(mrt):
mrt.assert_all_reversible()
$ pytest test_migrations.py -s
──────────── MRT — Migration Rollback Test ────────────
✓ 001 reversible
✓ 002 reversible
✓ 003 reversible
✗ 004 data loss detected
└─ Table 'users': 3/3 rows lost after rollback
✗ 005 data loss detected
└─ Table 'users' still exists after rollback — downgrade is incomplete
╭─────────────────────────────────────────────────────╮
│ 2 migration(s) will cause data loss on rollback. │
╰─────────────────────────────────────────────────────╯
You can also check a single revision — useful in CI to only gate new migrations in a PR:
def test_this_pr(mrt):
result = mrt.check_revision("abc123")
assert result.passed, result.failure_summary()
Static analysis
No database needed. Scan your migration files directly:
mrt check migrations/versions/
╭──────────┬──────────────────────────────┬─────────┬──────────────────────────────────────────────────╮
│ Revision │ Pattern │ Sev │ Message │
├──────────┼──────────────────────────────┼─────────┼──────────────────────────────────────────────────┤
│ 004 │ DROP COLUMN in upgrade │ error │ Column dropped — data permanently lost on rollback│
│ 005 │ No-op downgrade │ error │ downgrade() does nothing — migration irreversible │
│ 006 │ ENUM value added │ error │ Cannot roll back if rows use the new value │
│ 007 │ INDEX without CONCURRENTLY │ warning │ Locks table during index build │
╰──────────┴──────────────────────────────┴─────────┴──────────────────────────────────────────────────╯
3 error(s), 1 warning(s)
Add --strict to make warnings fail the build too.
What gets caught
Errors — these will cause data loss or a broken rollback:
| Pattern | Why |
|---|---|
op.drop_column() in upgrade |
Column data is gone even after rollback re-adds the column |
op.drop_table() in upgrade |
Every row in the table is permanently lost |
TRUNCATE in migration |
Destroys data with no undo |
def downgrade(): pass |
Rollback silently does nothing |
No downgrade() function |
Migration is completely irreversible |
rename_table without reverse |
Table stays under new name after rollback |
rename_column without reverse |
App code using old column name breaks |
DROP VIEW without recreating |
Application queries fail after rollback |
ALTER TYPE ... ADD VALUE |
Can't remove enum values once rows use them |
| Add + migrate data + drop original | The combined operation cannot be undone |
Warnings — worth reviewing before deploying:
| Pattern | Why |
|---|---|
NOT NULL without server_default |
Fails on non-empty tables |
| Column type change | Conversion may be lossy |
Raw op.execute() |
Content can't be verified automatically |
Bulk UPDATE without reverse UPDATE |
One-way data transformation |
ON DELETE CASCADE added |
Child rows silently deleted with parent |
CREATE INDEX without CONCURRENTLY |
Locks table during build (PostgreSQL) |
ADD COLUMN with DEFAULT |
Full table rewrite on PostgreSQL < 11 |
CREATE UNIQUE CONSTRAINT |
Fails if duplicates already exist |
DROP INDEX without recreating |
Query performance and uniqueness not restored |
DROP CONSTRAINT without recreating |
Data integrity guarantees removed |
ALTER SEQUENCE / setval |
Sequences don't roll back — gaps appear |
NOT NULL via raw SQL without reverse |
Column stays NOT NULL after rollback |
NOT NULL without restoring nullable |
Downgrade leaves column in wrong state |
How the dynamic check works
For each revision, pytest-mrt:
- Takes a snapshot of the current schema
- Seeds real rows into every table (type-aware: generates valid integers, strings, timestamps, UUIDs, etc.)
- Runs
alembic upgradeto the revision - Runs
alembic downgrade -1 - Checks the schema is exactly restored — no missing tables, no leftover tables
- Checks every seeded row is still there
This catches things static analysis can't: a migration where the schema comes back but the data doesn't, or a downgrade() that creates the table empty instead of restoring it.
Databases
| Static analysis | Dynamic verification | |
|---|---|---|
| PostgreSQL | ✅ | ✅ |
| SQLite | ✅ | ✅ |
| MySQL / MariaDB | ✅ | 🔜 planned |
CI
- name: Check migrations
run: |
mrt check migrations/versions/
pytest tests/test_migrations.py -v -s
For publishing via GitHub Actions with OIDC (no tokens needed), see the publish workflow.
Examples
examples/blog/ has a complete Alembic project with intentionally safe and unsafe migrations. Run it to see what pytest-mrt catches:
cd examples/blog
pytest test_migrations.py -v -s
Contributing
New risk patterns are the most valuable contribution. If you've been burned by a migration pattern that pytest-mrt doesn't catch, open an issue or PR. See CONTRIBUTING.md.
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-0.2.0.tar.gz.
File metadata
- Download URL: pytest_mrt-0.2.0.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: Hatch/1.17.0 {"ci":null,"cpu":"arm64","distro":{"name":"macOS","version":"26.4.1"},"implementation":{"name":"CPython","version":"3.14.3"},"installer":{"name":"hatch","version":"1.17.0"},"openssl_version":"OpenSSL 3.6.2 7 Apr 2026","python":"3.14.3","system":{"name":"Darwin","release":"25.4.0"}} HTTPX2/2.3.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9dc553e98fcc35d534306e8fa7ce13cc44c5304d118490c80c667988742f55a5
|
|
| MD5 |
aa35f974e089230301f71de9cb1c8372
|
|
| BLAKE2b-256 |
181738abd35f2ecfe3ac015263abe38f53c698b09b2a15f9bab9618b12af8281
|
File details
Details for the file pytest_mrt-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pytest_mrt-0.2.0-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: Hatch/1.17.0 {"ci":null,"cpu":"arm64","distro":{"name":"macOS","version":"26.4.1"},"implementation":{"name":"CPython","version":"3.14.3"},"installer":{"name":"hatch","version":"1.17.0"},"openssl_version":"OpenSSL 3.6.2 7 Apr 2026","python":"3.14.3","system":{"name":"Darwin","release":"25.4.0"}} HTTPX2/2.3.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4e27b4934249d4b0c584c5bf1b9a1e16b80db0671692dcf40fc0ccd3c336bf9c
|
|
| MD5 |
b9ba921eb8e7346b639dbd23142bfea5
|
|
| BLAKE2b-256 |
3381700c477e1b39aa801b69059458e652cf332f510668462e8d48923ee6443e
|