migraguard
Django migration safety linter for zero-downtime deploys. Migrations that are fine on a laptop can lock a big PostgreSQL table for minutes or break application servers that are still running the previous release during a rolling deploy. migraguard statically analyzes your migration files (and, optionally, the exact SQL Django generates for them), flags the unsafe operations, explains why, and shows the safe pattern, so you can run it as a CI gate on every pull request.
Features
- Settings-free discovery: walks
*/migrations/[0-9]*.pywith theastmodule. No Django import, no database, works on a bare CI checkout. - Migration graph: dependencies,
replaces/squashed migrations,__first__/__latest__, conflicting leaf nodes, broken dependencies, models without a migration. - Git base mode:
--base origin/mainlints only the migrations added or changed on your branch (merge-base aware, includes uncommitted files). - 18 operation rules (MG001-MG018): NOT NULL columns, callable defaults,
db_defaultawareness, RemoveField/RenameField/RenameModel/DeleteModel, table-rewriting AlterField (field state is tracked across migrations), non-concurrent indexes and unique constraints,atomic = Truewith concurrent operations, CHECK/FK withoutNOT VALID,RunPython/RunSQLwithout reverse, unbatchedRunPythonloops, data and schema mixed in one transaction,SeparateDatabaseAndState. - SQL mode (
--settings): renders the real PostgreSQL DDL with Django's schema editor (collect_sql) without a database, attaches it to findings and addsALTER TABLE ... ADD COLUMN ... NOT NULL,CREATE INDEXwithoutCONCURRENTLY,SET NOT NULL, ... rules (MG2xx). - Table size awareness (
--table-sizes sizes.csv): lock-heavy findings become errors only for big tables. - Fix suggestions with code: the nullable -> backfill -> constrain pattern,
AddIndexConcurrentlywithatomic = False,AddConstraintNotValid+ValidateConstraint, a batchingRunPythontemplate, ... - Outputs: rich terminal, JSON, GitHub Actions annotations, SARIF 2.1.0.
- Configuration:
[tool.migraguard]inpyproject.toml(ignore rules, per-migration ignores, severity overrides, thresholds) and inline# migraguard: ignore[MG003].
Install
pip install -e . # lint only: needs just click and rich
pip install -e '.[sql]' # SQL mode: adds django and psycopg (no database server needed)
pip install -e '.[dev]' # tests
Python 3.12+. Rules target PostgreSQL semantics.
Quickstart
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/pip install -e examples/shop # optional: makes the example importable for SQL mode
.venv/bin/migraguard lint examples/shop # exit code 1: the example is intentionally unsafe
./examples/walkthrough.sh # full guided run (needs `migraguard` on PATH)
Usage
Lint a project
migraguard lint [PATH] (default .) accepts the project root; every app with a migrations/ package below it is analysed. Output from the bundled example (--no-hints hides the code snippets):
migraguard 0.1.0: 12 of 12 migrations linted
examples/shop/accounts/migrations/0002_customer_phone.py
warning MG002 8:9 AddField customer.phone is NOT NULL with a Python-side
default and no db_default; old code that omits the column breaks
INSERTs during the deploy.
examples/shop/accounts/migrations/0003_customer_tier_referrer.py
warning MG011 14:9 AddField customer.referrer adds a foreign key constraint
and index under lock.
examples/shop/catalog/migrations/0002_product_sku_slug.py
warning MG002 10:9 AddField product.sku is NOT NULL with a Python-side
callable default and no db_default; old code that omits the column
breaks INSERTs during the deploy.
error MG001 11:9 AddField product.slug is NOT NULL with no default; it
fails on non-empty tables.
warning MG007 11:9 AddField product.slug creates an index non-concurrently.
examples/shop/catalog/migrations/0003_product_indexes.py
warning MG007 8:9 AddIndex on product builds the index without
CONCURRENTLY.
warning MG008 12:9 AlterUniqueTogether on product adds unique index
(category, name) under a write lock.
examples/shop/catalog/migrations/0005_product_cleanup.py
error MG004 8:9 RenameField product.stock -> quantity breaks servers
still running the old release.
warning MG006 9:9 AlterField product.quantity changes the column type
IntegerField -> BigIntegerField (table rewrite).
warning MG006 10:9 AlterField product.name shrinks max_length 200 -> 100
(table scan, may fail on existing data).
warning MG006 11:9 AlterField product.description adds NOT NULL (full table
scan under ACCESS EXCLUSIVE lock).
warning MG003 12:9 RemoveField product.legacy_code drops a column that old
code may still read.
examples/shop/orders/migrations/0002_order_constraints.py
warning MG010 9:9 AddConstraint CheckConstraint on order validates all rows
under an ACCESS EXCLUSIVE lock.
error MG009 13:9 AddIndexConcurrently requires `atomic = False` on the
Migration class.
examples/shop/orders/migrations/0003_backfill_status.py
warning MG012 16:9 RunPython has no reverse_code; the migration cannot be
rolled back.
info MG013 16:9 RunPython is not marked elidable=True.
warning MG014 16:9 backfill_status() loops over a whole queryset (line 6)
in one transaction without iterator()/batching.
warning MG017 16:9 RunPython runs in the same atomic migration as 1 schema
operation(s).
warning MG015 17:9 RunSQL has no reverse_sql; the migration cannot be
rolled back.
examples/shop/orders/migrations/0004_line_items.py
error MG005 8:9 RenameModel OrderItem -> LineItem renames a table old
code still queries.
warning MG016 9:9 DeleteModel Coupon drops a table old code may still use.
warning MG018 10:9 SeparateDatabaseAndState: verify database and state
operations agree.
4 error(s), 17 warning(s), 1 info; 0 suppressed
By default each rule's safe pattern is printed once, the first time the rule fires. A shortened excerpt:
migraguard 0.1.0: 12 of 12 migrations linted
examples/shop/orders/migrations/0003_backfill_status.py
warning MG014 16:9 backfill_status() loops over a whole queryset (line 6)
in one transaction without iterator()/batching.
fix:
def forwards(apps, schema_editor):
Model = apps.get_model("orders", "Order")
batch = []
for obj in Model.objects.only("pk", "<field>").iterator(chunk_size=2000):
obj.<field> = ...
batch.append(obj)
if len(batch) >= 2000:
Model.objects.bulk_update(batch, ["<field>"])
batch.clear()
Model.objects.bulk_update(batch, ["<field>"])
Prefer a single UPDATE ... WHERE or Model.objects.filter(...).update(...) when the logic is expressible in SQL,
and set atomic = False on the migration to commit per batch.
0 error(s), 1 warning(s), 0 info; 21 suppressed
Lint only what a pull request adds (git base mode)
migraguard lint . --base origin/main
migraguard resolves git merge-base <ref> HEAD, takes the files that were added or modified since then (plus untracked files), and lints just those migrations. The rest of the graph is still read so field state and conflict detection stay correct. Real run in a temp repo where a branch adds two 0006 migrations to catalog and one of them adds a NOT NULL column:
$ migraguard lint . --base main
migraguard 0.1.0: 2 of 14 migrations linted (new vs main)
catalog/migrations/0006_product_barcode.py
error MG001 8:9 AddField product.barcode is NOT NULL with no default; it
fails on non-empty tables.
fix:
Add the column nullable, backfill in batches, then tighten it in a later deploy:
# 1st migration: schema only
migrations.AddField("product", "barcode", models.<Type>(null=True))
# 2nd migration: batched RunPython backfill (see MG014)
# 3rd migration: constrain once every row has a value
migrations.AlterField("product", "barcode", models.<Type>(default=...))
On Django 5.0+ a single step is also safe: models.<Type>(db_default=...).
catalog/migrations/0006_product_weight.py
error MG100 1:1 app 'catalog' has 2 leaf migrations:
0006_product_barcode, 0006_product_weight.
fix:
python manage.py makemigrations --merge
2 error(s), 0 warning(s), 0 info; 0 suppressed
exit code: 1
An already-committed migration that the branch edits is reported as MG102. migraguard graph . prints the graph and exits 1 on conflicts:
accounts
0001_initial
0002_customer_phone <- 0001_initial
0003_customer_tier_referrer [leaf] <- 0002_customer_phone
catalog
0001_initial
0002_product_sku_slug <- 0001_initial
0003_product_indexes <- 0002_product_sku_slug
0004_product_price_index <- 0003_product_indexes
0005_product_cleanup [leaf] <- 0004_product_price_index
orders
0001_initial <- accounts.0001_initial, catalog.0001_initial
0002_order_constraints <- 0001_initial
0003_backfill_status <- 0002_order_constraints
0004_line_items [leaf] <- 0003_backfill_status
SQL mode
migraguard lint . --base main --settings shop.settings
--settings points at an importable Django settings module (the linted PATH is put on sys.path, so a project root containing manage.py just works). migraguard builds a PostgreSQL schema editor in collect_sql mode, replays each migration against the historical project state and never opens a connection, so it works in CI without Postgres, whatever DATABASES says. SQL that confirms an AST finding is attached to it (see sql in JSON/SARIF); SQL problems the AST rules cannot see (for example a raw RunSQL) become MG2xx findings. The generated SQL of flagged migrations is printed:
migraguard 0.1.0: 2 of 14 migrations linted (new vs main)
catalog/migrations/0006_product_barcode.py
error MG001 8:9 AddField product.barcode is NOT NULL with no default; it
fails on non-empty tables.
SQL generated for catalog.0006_product_barcode:
ALTER TABLE "catalog_product" ADD COLUMN "barcode" varchar(32) NOT NULL;
catalog/migrations/0006_product_weight.py
error MG100 1:1 app 'catalog' has 2 leaf migrations:
0006_product_barcode, 0006_product_weight.
SQL generated for catalog.0006_product_weight:
ALTER TABLE "catalog_product" ADD COLUMN "weight" integer NULL;
2 error(s), 0 warning(s), 0 info; 0 suppressed
Install psycopg (pip install 'migraguard[sql]'); it is only used for its SQL quoting.
Table sizes
migraguard lint . --table-sizes sizes.csv
table,rows
catalog_product,2500000
orders_order,40
Rules marked size-aware (index builds, unique constraints, CHECK/FK validation, table rewrites, unbatched data loops) are raised to error when the table has at least big-table-rows rows (default 100,000). Small or unknown tables keep their default severity.
catalog/migrations/0003_product_indexes.py
error MG007 8:9 AddIndex on product builds the index without
CONCURRENTLY. Table has 2,500,000 rows (>= 100,000).
error MG008 12:9 AlterUniqueTogether on product adds unique index
(category, name) under a write lock. Table has 2,500,000 rows (>=
100,000).
Output formats
--format |
Use |
|---|---|
rich (default) |
terminal report with fix snippets |
json |
machine readable, includes summary, SQL per migration and notes |
github |
::error file=...,line=...:: annotations for GitHub Actions |
sarif |
SARIF 2.1.0 for GitHub code scanning and other viewers |
::error file=examples/shop/catalog/migrations/0002_product_sku_slug.py,line=11,endLine=11,col=9,title=MG001 add-not-null-no-default::AddField product.slug is NOT NULL with no default; it fails on non-empty tables.
::warning file=examples/shop/catalog/migrations/0002_product_sku_slug.py,line=11,endLine=11,col=9,title=MG007 add-index-not-concurrent::AddField product.slug creates an index non-concurrently.
-o FILE writes any format to a file. A GitHub Actions job:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pip install migraguard
- run: migraguard lint . --base origin/${{ github.base_ref }} --format github
- run: migraguard lint . --base origin/${{ github.base_ref }} --format sarif -o migraguard.sarif
if: always()
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: migraguard.sarif }
Other commands
$ migraguard rules # list every rule
MG001 error AddField is NOT NULL with no default
MG002 warning AddField is NOT NULL with a Python-side default only
MG003 warning RemoveField drops a column that running code may still read
MG004 error RenameField breaks code that is still running
MG005 error RenameModel renames a table that old code still uses
MG006 warning AlterField changes the column in a way that rewrites or scans the table
MG007 warning Index is created without CONCURRENTLY
MG008 warning Unique constraint/index is built while holding a write lock
MG009 error Concurrent operation inside an atomic migration
MG010 warning Constraint is validated while holding a lock
MG011 warning Foreign key is added and validated under a lock
MG012 warning RunPython has no reverse_code
MG013 info RunPython is not marked elidable
MG014 warning RunPython iterates a whole queryset without batching
MG015 warning RunSQL has no reverse_sql
MG016 warning DeleteModel drops a table that old code may still use
MG017 warning Data migration mixed with schema changes in one atomic migration
MG018 warning SeparateDatabaseAndState needs manual review
MG100 error App has more than one leaf migration
MG101 warning Model has no migration
MG102 warning A migration that already exists on the base ref was modified
MG103 error Migration depends on a migration that does not exist
MG104 error Migration file could not be analysed
MG201 error SQL: ADD COLUMN ... NOT NULL without DEFAULT
MG202 warning SQL: CREATE INDEX without CONCURRENTLY
MG203 warning SQL: ALTER COLUMN ... SET NOT NULL
MG204 warning SQL: ADD CONSTRAINT (FOREIGN KEY/CHECK) without NOT VALID
MG205 warning SQL: ALTER COLUMN ... TYPE
MG206 warning SQL: DROP COLUMN/TABLE or RENAME
MG207 warning SQL: ADD CONSTRAINT ... UNIQUE
migraguard explain MG001 prints the rationale and safe pattern of one rule; migraguard rules --format markdown generates docs/rules.md.
Exit codes
| Code | Meaning |
|---|---|
| 0 | no finding at or above --fail-on (default error) |
| 1 | at least one finding at or above --fail-on (info, warning, error, none); also graph with conflicts |
| 2 | usage or configuration error (bad ref, unknown rule id, unreadable settings, no migrations found) |
Configuration
[tool.migraguard] in the nearest pyproject.toml (searched upward from PATH), or --config FILE:
[tool.migraguard]
fail-on = "warning" # info | warning | error | none
ignore = ["MG013"] # rules disabled everywhere
exclude-apps = ["legacy"]
big-table-rows = 250000
base = "origin/main" # same as --base
settings = "shop.settings" # same as --settings (enables SQL mode)
table-sizes = "sizes.csv" # relative to the pyproject.toml
[tool.migraguard.severity] # info | warning | error | off
MG003 = "error"
MG018 = "off"
[tool.migraguard.ignore-migrations] # "app.migration" globs -> rule ids ("*" = all)
"catalog.0002_*" = ["MG001", "MG002"]
"orders.0004_line_items" = ["*"]
Command-line options override the file. Unknown keys and rule ids are errors (exit 2), so typos do not silently disable checks.
Inline ignores
migrations.RemoveField("product", "legacy_code"), # migraguard: ignore[MG003]
A comment applies to the operation it is on, inside, or on the line above. A comment above class Migration applies to the whole file. # migraguard: ignore without ids ignores everything in that scope. Ignored findings are counted as suppressed in the summary.
Architecture
src/migraguard/
cli.py click commands: lint, rules, explain, graph
lint.py pipeline: discover -> scope (git) -> analyze -> SQL mode -> policy -> result
discovery.py AST walk of migrations, Migration/Project graph, leaves/conflicts, git diff
state.py field/table state replayed in dependency order (feeds AlterField checks)
rules.py one check function per operation type + migration-level checks
analyzer.py orchestration of rules and graph checks (MG1xx)
catalog.py rule metadata, rationale and fix templates (single source of truth)
sqlmode.py offline PostgreSQL SQL generation and SQL pattern rules (MG2xx)
config.py pyproject config, table sizes CSV, ignores, severity and size policy
report.py rich / json / github / sarif renderers, rules markdown
astutil.py CallInfo helper for reading calls/keywords as literals
examples/shop/ runnable Django project with 12 migrations covering every rule
docs/ rule catalog and design notes
See docs/design.md for how the graph, state tracking, SQL generation and severity policy work, with worked examples.
Tests
.venv/bin/pytest -q
Covers every rule (positive and negative fixtures), graph and squash handling, git base diffing in temp repos, config and ignores, table-size policy, SQL pattern matching and SQL mode against the example project (in subprocesses), SARIF structure, and the CLI.
Limitations
- Static analysis: migrations whose
operationsare built dynamically (loops, helper functions returning lists) are only partly visible. Non-literal arguments are skipped rather than guessed. - The app label is the directory that contains
migrations/; a customAppConfig.labelis not resolved. MG101detects models with noCreateModel, not changed fields (usemakemigrations --checkfor that).- Rules model PostgreSQL. Other databases will see extra, harmless findings.
- Version-dependent behaviour is assumed to be PostgreSQL 12+ (instant
ADD COLUMNwith constant default,SET NOT NULLusing a validated CHECK) and Django 5.0+ (db_default). - Operations nested inside
SeparateDatabaseAndStateare state-tracked but not linted individually. - The size policy only escalates; there is no downgrade for small tables.