Detection Accuracy Report

June 10, 2026 · View on GitHub

This document describes what each static analysis pattern in pytest-mrt detects, what it misses, and the false-positive risk for each check. The false-positive suite (tests/test_false_positives.py) enforces the "will NOT trigger" column automatically on every CI run.

Last updated: 2026-06-10 · pytest-mrt v1.4.0 · 34 Alembic patterns + 10 Django patterns


Overall accuracy summary

The numbers below are measured against the false-positive test suite (tests/test_false_positives.py, 310 cases) and the detection test suite (tests/test_patterns.py, 44 pattern × multiple variants each).

MetricAlembicDjango
Patterns covered3410
False-positive suite cases24862
Measured false-positive rate0 % (0 / 248)0 % (0 / 62)
Detection rate on synthetic cases100 %100 %
Known blind spots (architectural)84

False-positive rate is enforced at 0 % by CI — the false-positive suite fails the build if any safe migration triggers a warning. The "Medium" or "Low" labels in the per-pattern table below refer to theoretical risk from code patterns that are syntactically similar to unsafe ones, not observed misfires.

Detection rate on real-world projects is not yet formally measured. If you find a pattern that pytest-mrt misses, please open an issue tagged accuracy.


How to read this table

ColumnMeaning
Severityerror — blocks rollback; warning — may block or degrade
Will catchCases the pattern reliably detects
Will NOT catchKnown blind spots (not false negatives from bugs — architectural limits)
False-positive riskHow often the pattern fires on safe code

Alembic patterns (34)

Per-file checks

1. Missing downgrade

Severityerror
Will catchMigration file with no downgrade() function at all
Will NOT catchdowngrade() that exists but does nothing useful (covered by #2)
False-positive riskNone — a missing function is unambiguous

2. No-op downgrade

Severityerror
Will catchdowngrade() whose body is pass or a bare docstring/comment
Will NOT catchDowngrade that calls a helper which is itself a no-op
False-positive riskNone — AST check confirms the body is effectively empty

3. DROP COLUMN in upgrade

Severityerror
Will catchAny op.drop_column() in upgrade()
Will NOT catchColumn dropped via raw ALTER TABLE ... DROP COLUMN SQL
False-positive riskLow — intentional destructive drops should be in dedicated migrations with documented rationale

4. DROP TABLE in upgrade

Severityerror
Will catchAny op.drop_table() in upgrade()
Will NOT catchDROP TABLE via op.execute() raw SQL
False-positive riskLow — same rationale as #3

5. TRUNCATE

Severityerror
Will catchop.execute("TRUNCATE ...") or op.execute(sa.text("TRUNCATE ...")) in upgrade()
Will NOT catchDELETE FROM without a WHERE clause (functionally equivalent but different SQL)
False-positive riskNone — TRUNCATE is always destructive

6. NOT NULL without default

Severitywarning
Will catchop.add_column / op.alter_column with nullable=False and no server_default or default
Will NOT catchNOT NULL added via raw SQL ALTER TABLE ... SET NOT NULL (covered by #25)
False-positive riskLow — fires on new tables too; intentional cases can be suppressed with # noqa: MRT201

7. Column type change

Severitywarning
Will catchop.alter_column(..., type_=...) in upgrade()
Will NOT catchType change via raw ALTER TABLE ... ALTER COLUMN ... TYPE SQL
False-positive riskMedium — safe casts (e.g. VARCHAR(50)VARCHAR(100)) also trigger; validate manually

8. Raw SQL (op.execute)

Severitywarning
Will catchop.execute() in upgrade() with no corresponding execute in downgrade()
Will NOT catchSafe read-only SELECT in execute (SELECT never needs reversal — but this check fires regardless)
False-positive riskMedium — DDL-only migrations that also SELECT will trigger; a matching execute in downgrade suppresses it

9. Data transform without reverse

Severitywarning
Will catchBulk UPDATE in upgrade() with no UPDATE in downgrade()
Will NOT catchINSERT or DELETE data transforms without reversal (partial coverage)
False-positive riskLow — one-way data transforms are rarely intentional without documentation

10. CASCADE DELETE

Severitywarning
Will catchondelete="CASCADE" on any FK definition in upgrade()
Will NOT catchCascade behavior defined in database triggers outside migration files
False-positive riskMedium — some schemas intentionally use cascade; audit each finding

11. INDEX without CONCURRENTLY

Severitywarning
Will catchop.create_index() without postgresql_concurrently=True
Will NOT catchIndex creation via raw SQL without CONCURRENTLY keyword
False-positive riskMedium — non-PostgreSQL databases don't support CONCURRENTLY; safe to ignore for SQLite/MySQL

12. ADD COLUMN with volatile DEFAULT

Severitywarning
Will catchop.add_column(..., Column(..., default=...)) — Python-side default rewrites the table on all PG versions
Will NOT catchDefault expressed as a callable or ORM-level event
False-positive riskLow — Python-side defaults in migrations almost always cause table rewrites

13. ADD COLUMN with server_default

Severitywarning
Will catchop.add_column(..., Column(..., server_default=...)) — rewrites table on PostgreSQL < 11
Will NOT catchServer defaults set via op.execute("ALTER TABLE ... SET DEFAULT ...")
False-positive riskMedium — safe on PostgreSQL 11+; verify your database version

14. UNIQUE constraint on existing data

Severitywarning
Will catchAny op.create_unique_constraint() in upgrade()
Will NOT catchUNIQUE added via raw SQL or as part of a new table creation
False-positive riskMedium — safe on empty tables or freshly populated ones; check for duplicates before deploying

15. DROP INDEX without reverse

Severitywarning
Will catchop.drop_index() in upgrade() with no op.create_index() in downgrade()
Will NOT catchIndex drop via raw SQL
False-positive riskLow — intentional permanent index removal is uncommon without documentation

16. DROP CONSTRAINT without reverse

Severitywarning
Will catchop.drop_constraint() in upgrade() without a matching create_foreign_key / create_unique_constraint / create_check_constraint / create_primary_key in downgrade()
Will NOT catchConstraint drop via raw SQL
False-positive riskLow

17. rename_table without reverse

Severityerror
Will catchop.rename_table(old, new) without op.rename_table(new, old) in downgrade()
Will NOT catchRename via raw SQL ALTER TABLE ... RENAME TO
False-positive riskNone — verifies both argument positions exactly

18. rename_column without reverse

Severityerror
Will catchop.alter_column(..., new_column_name=...) in upgrade() without corresponding rename in downgrade()
Will NOT catchColumn rename via raw SQL
False-positive riskNone — checks for the presence of new_column_name kwarg

19. DROP VIEW without reverse

Severityerror
Will catchop.execute("DROP VIEW ...") in upgrade() without CREATE VIEW in downgrade()
Will NOT catchView dropped via dialect-specific API
False-positive riskLow

20. SEQUENCE modification

Severitywarning
Will catchCREATE SEQUENCE, ALTER SEQUENCE, or setval(...) in upgrade()
Will NOT catchSequence manipulation via ORM-level events
False-positive riskLow — sequences are not transactional; the warning is almost always relevant

21. ENUM value added

Severityerror
Will catchALTER TYPE ... ADD VALUE in upgrade() (PostgreSQL-specific)
Will NOT catchENUM changes on MySQL (different syntax), ENUM type replacement
False-positive riskNone — ADD VALUE is irreversible once any row uses the new value

22. Multi-step destructive migration

Severityerror
Will catchMigration that adds a column, bulk-updates data into it, and drops the original column — all in one step
Will NOT catchSame pattern spread across multiple migrations
False-positive riskLow — the combination of add + UPDATE + drop in one migration is inherently unsafe

23. NOT NULL without reverting nullable

Severitywarning
Will catchop.alter_column(..., nullable=False) in upgrade() without nullable=True in downgrade()
Will NOT catchNOT NULL set via raw SQL
False-positive riskLow

24. NOT NULL via raw SQL without reverse

Severitywarning
Will catchALTER TABLE ... SET NOT NULL in upgrade() without DROP NOT NULL in downgrade()
Will NOT catchNOT NULL enforced by a CHECK constraint
False-positive riskLow

25. bulk_insert without reverse

Severitywarning
Will catchop.bulk_insert() in upgrade() without op.delete() or DELETE SQL in downgrade()
Will NOT catchData inserted via op.execute("INSERT ...") — covered by #8
False-positive riskLow

26. context.execute without reverse

Severitywarning
Will catchcontext.execute() / ctx.execute() / conn.execute() / connection.execute() in upgrade() without a matching execute in downgrade()
Will NOT catchExecute called through an arbitrary variable name
False-positive riskMedium — same caveats as #8; a matching execute in downgrade suppresses it

27. DROP COLUMN in batch_alter_table

Severityerror
Will catchop.drop_column() inside op.batch_alter_table() context manager in upgrade()
Will NOT catchDrop occurring outside a batch context (covered by #3)
False-positive riskNone

28. DROP CONSTRAINT in batch_alter_table

Severitywarning
Will catchop.drop_constraint() inside op.batch_alter_table() without recreating it in downgrade()
Will NOT catchConstraint dropped outside batch context (covered by #16)
False-positive riskLow

29. DROP FOREIGN KEY without restore

Severityerror
Will catchop.drop_constraint(type_='foreignkey') in upgrade() without op.create_foreign_key() in downgrade()
Will NOT catchFK dropped via raw ALTER TABLE ... DROP FOREIGN KEY SQL
False-positive riskLow — referential integrity should always be restored on rollback

30. CREATE TRIGGER without DROP TRIGGER

Severityerror
Will catchCREATE TRIGGER SQL in upgrade() without DROP TRIGGER SQL in downgrade()
Will NOT catchTrigger created via a stored procedure or database-specific API
False-positive riskLow

31. CREATE TYPE without DROP TYPE

Severityerror
Will catchCREATE TYPE SQL in upgrade() without DROP TYPE SQL in downgrade()
Will NOT catchCustom type created via SQLAlchemy TypeDecorator at ORM level
False-positive riskLow — custom types left behind block re-running the migration

Cross-migration graph checks

G1. Multiple migration heads

Severityerror
Will catchTwo migration files that both reference the same down_revision (branched history)
Will NOT catchMerge migrations already using a tuple down_revision
False-positive riskNone — detects unresolved branches that will cause alembic upgrade head to fail

G2. Data hole chain

Severitywarning
Will catchMigration A drops column X; migration B adds column X back — schema is restored on rollback but original data is permanently gone
Will NOT catchThe same pattern across non-adjacent migrations in a long chain
False-positive riskLow — the structural pattern (drop then re-add same column name) is rarely coincidental

G3. Orphaned migration

Severitywarning
Will catchMigration files unreachable from any current head in the dependency graph
Will NOT catchOrphans in branches that were intentionally kept separate
False-positive riskMedium — feature branches and squashed migrations may appear as orphans; use # noqa: MRTXXX on the line to suppress

Django patterns (10)

D1. RemoveField

Severityerror
Will catchmigrations.RemoveField(...) — permanent column data loss on rollback
Will NOT catchField removed via RunSQL
False-positive riskNone

D2. DeleteModel

Severityerror
Will catchmigrations.DeleteModel(...) — all table data lost on rollback
Will NOT catchTable dropped via RunSQL
False-positive riskNone

D3. AddField NOT NULL (no default)

Severityerror
Will catchAddField with null=False and no default= — will fail on non-empty tables
Will NOT catchNOT NULL field added via RunSQL
False-positive riskLow

D4. AlterField NOT NULL (no default)

Severitywarning
Will catchAlterField changing an existing field to null=False without a default=
Will NOT catchNULL constraint change via RunSQL
False-positive riskLow

D5. RunSQL without reverse_sql

Severitywarning
Will catchRunSQL(forward_sql) without a reverse_sql argument
Will NOT catchReverse SQL that is present but logically incorrect
False-positive riskLow — read-only SQL in RunSQL is uncommon; add reverse_sql="" to document intentional one-way operations

D6. RunSQL with TRUNCATE/DROP

Severityerror
Will catchRunSQL containing TRUNCATE or DROP TABLE
Will NOT catchDestructive SQL in a helper called from RunSQL
False-positive riskNone

D7. RunPython without reverse_code

Severitywarning
Will catchRunPython(forwards_func) without reverse_code= argument
Will NOT catchReverse function that exists but does nothing (not checked for no-op)
False-positive riskLow

D8. RenameModel without reverse

Severityerror
Will catchmigrations.RenameModel(old_name, new_name) — checks that downgrade performs the inverse rename
Will NOT catchRename via RunSQL
False-positive riskNone

D9. AddIndex without atomic=False

Severitywarning
Will catchAddIndex on a migration without atomic = FalseCREATE INDEX CONCURRENTLY requires a non-atomic migration on PostgreSQL
Will NOT catchIndex added via RunSQL with CONCURRENTLY
False-positive riskMedium — only relevant for PostgreSQL with CONCURRENTLY; MySQL does not have this requirement

D10. Dangerous DDL without atomic=False

Severitywarning
Will catchRemoveField or DeleteModel inside an atomic migration — on some databases these cannot be rolled back even within a transaction
Will NOT catchMulti-database setups where only one database requires non-atomic
False-positive riskLow

Summary

ScopeTotal patternsErrorWarning
Alembic per-file311318
Alembic graph312
Django1055
Total441925

False-positive risk distribution across all 44 patterns:

Risk levelCount
None17
Low17
Medium8

The false-positive test suite (tests/test_false_positives.py) enforces 30+ cases that must produce zero warnings, covering the most common medium-risk patterns.


Suppressing false positives

When a pattern fires on intentional code, suppress it with # noqa: MRTxxx on the offending line (same convention as ruff/flake8):

def upgrade():
    op.drop_column("users", "legacy_col")  # noqa: MRT103
    op.alter_column("orders", "amount", type_=sa.Numeric)  # noqa: MRT202

Use a bare # noqa to suppress all MRT warnings on a line. The legacy # mrt: ignore syntax is also supported.


Reporting accuracy issues

If you find a false positive or false negative, open an issue tagged accuracy at github.com/croc100/pytest-mrt/issues. Include the migration file and the unexpected finding.