CLI ยท commit 9ad9e00

migraguard

Django migration safety linter for zero-downtime deploys

Download migraguard.zip
Quickstart
unzip migraguard.zip && cd migraguard
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

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

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