django-orm-lens migration-risk --path . statically analyzes every <app>/migrations/*.py in the workspace via ast.parse — no Django boot, no database connection, no execution of the migration. It implements 16 rules over the operations = [...] list of each Migration class. Every finding carries a severity (critical | warning | info) and a confidence (high | medium | low), plus the description and mitigation shown below.
Usage and CI semantics:
- Exit code:
1only whencriticalfindings remain after filtering;0otherwise.--exit-zeroalways exits0— useful while burning down existing debt. --severity critical|warning|info|all(defaultcritical) — report findings at or above the given severity. The default surfaces only migration-breaking issues; use--severity allfor the full review list.--format text|json|sarif|github—sarifemits SARIF 2.1.0 for upload to GitHub Code Scanning;githubemits::error/::warningworkflow commands for zero-setup PR annotations.jsonis the machine-readable finding list;textis the human default.
Populated-table heuristic: any migration after the app's first (0001_*) is assumed to run against a table that already contains rows. Rules marked populated tables only stay silent in an app's initial migration. Rules that cross-reference the current models.py degrade to a low-confidence *_unverified variant when the workspace schema cannot be parsed.
Operation: AddField · Severity: critical · Confidence: high · populated tables only
Adds a NOT NULL column (no null=True) without a default to a model whose table likely has rows; existing rows will raise IntegrityError when the migration applies. Auto-created PK fields (AutoField / BigAutoField / SmallAutoField) are exempt.
Mitigation: Add null=True first, backfill via RunPython, then a third migration to set null=False. Or provide default=.
Operation: AddField · Severity: critical (high confidence) — downgraded to warning (medium confidence) when the default is a callable · populated tables only
Adds a UNIQUE column to a populated table. Without a per-row default, every existing row gets the same value and the constraint fails on the first duplicate. A callable default (e.g. default=uuid.uuid4) can produce unique per-row values, so that case is flagged for verification rather than as a blocker.
Mitigation: Add the column nullable first, populate unique values via data migration, then apply the unique constraint.
Operation: RemoveField · Severity: critical · Confidence: high
Removes a field while a field with the same name still exists in the current models.py — running code will keep reading/writing a column the migration drops.
Mitigation: Confirm no code path still reads/writes the field. Deploy the code change first, then run this migration.
Operation: RemoveField · Severity: info · Confidence: low
Removes a field, but the analyzer could not cross-reference the current models.py (schema parse unavailable). Flagged so reviewers see dropped columns at a glance.
Mitigation: Manually confirm no code paths still access the removed field.
Operation: DeleteModel · Severity: critical · Confidence: high
Deletes a model whose class is still defined in models.py — the running application will keep querying a table the migration drops.
Mitigation: Remove the model class from models.py in a prior deploy, or drop this operation.
Operation: DeleteModel · Severity: info · Confidence: low
Deletes a model, but the analyzer could not cross-reference the current models.py.
Mitigation: Manually confirm no code paths still import or query the model.
Operation: RenameField · Severity: warning · Confidence: medium
Renames a column. During a rolling deploy, app instances still running the old code query the old column name and fail until every instance has restarted.
Mitigation: Use a 3-step migration: add the new column, dual-write and backfill, then drop the old column after all instances have restarted.
Operation: RenameModel · Severity: warning · Confidence: medium
Renames a model (and therefore its table). Old app instances during a rolling deploy query the old table and fail.
Mitigation: Use db_table on the new model to keep the same table name, or stage the rename via new model + data copy + old model drop.
Operation: AddIndex · Severity: warning · Confidence: medium · populated tables only
A plain AddIndex takes an ACCESS EXCLUSIVE lock on Postgres for the duration of the index build, blocking writes to the table. (AddIndexConcurrently and RemoveIndex are recognized as the safe variants and never flagged.)
Mitigation: Use AddIndexConcurrently (Django 3.0+) inside a non-atomic migration, or wrap CREATE INDEX CONCURRENTLY in RunSQL with SET LOCAL lock_timeout to bail early.
Operation: AlterField · Severity: warning · Confidence: medium
Alters a column to CharField(max_length=N). If this narrows the column, rows longer than N characters make the migration fail; either way Postgres may rewrite the table under an ACCESS EXCLUSIVE lock.
Mitigation: Verify no existing row exceeds the new length. Use ALTER TABLE ... ALTER COLUMN ... TYPE with USING and run out of hours if the table is large.
Operation: AlterField · Severity: warning · Confidence: medium
Alters a column across integer field types (SmallIntegerField / IntegerField / BigIntegerField and their Positive* variants). On Postgres < 12 a column type change rewrites the whole table under an ACCESS EXCLUSIVE lock.
Mitigation: On PG12+ widening is metadata-only; narrowing still rewrites. Stage via new column + backfill + swap for large tables.
Operation: RunSQL · Severity: warning · Confidence: medium
RunSQL without reverse_sql cannot be rolled back; migrating backwards past it fails — usually mid-incident, exactly when rollback matters.
Mitigation: Pass reverse_sql=... (or migrations.RunSQL.noop if the operation is genuinely irreversible) so the migration is reversible in CI/rollback.
Operation: RunPython · Severity: warning · Confidence: medium
RunPython without reverse_code cannot be rolled back; migrating backwards past this data migration will fail.
Mitigation: Pass reverse_code=migrations.RunPython.noop when the forward step is safe to leave in place, or a real inverse function when it is not.
Operation: AlterUniqueTogether · Severity: warning · Confidence: medium · populated tables only
Adds/changes unique_together on a populated table: Postgres builds and validates a unique index under an ACCESS EXCLUSIVE lock, and any existing duplicate rows make the migration fail mid-deploy. Removing the constraint (unique_together=set() or []) is safe and not flagged.
Mitigation: Deduplicate rows first, then prefer AddConstraint(UniqueConstraint) — on Postgres create the unique index CONCURRENTLY (RunSQL + SeparateDatabaseAndState) for large tables. unique_together is soft-deprecated in favour of UniqueConstraint.
Operation: AlterIndexTogether · Severity: info · Confidence: high
Uses index_together, deprecated since Django 4.2 and removed in 5.1 — this migration will not run on modern Django at all.
Mitigation: Squash or rewrite the operation as Meta.indexes with AddIndex / RenameIndex (see the Django 4.2 release notes migration path).
Operation: (migration graph) · Severity: critical · Confidence: high
The app's migration graph has more than one leaf — two or more migrations that nothing else in the app depends on. This is what happens when two branches each add a migration on the same parent and both get merged. Django refuses to run the app at all: "Conflicting migrations detected; multiple leaf nodes in the migration graph."
Unlike every other rule here, this one is a property of the whole graph rather than of a single operation, so it is reported once per conflicting leaf — each finding names the migrations it collides with, and every offending file gets annotated in a pull request.
The check reads the dependencies tuples only, so it fires on a fresh clone and in CI, before anything has a settings module or a database. makemigrations --merge cannot tell you this until you already have both.
Mitigation: Run python manage.py makemigrations --merge to generate a merge migration depending on every leaf. If the branches touched the same table, review the merged result by hand before applying it.
Note on app labels: dependencies carries the Django app label, which AppConfig.label may set to something other than the package directory name. The rule recovers the real label from the dependencies themselves and stays silent when that is ambiguous — a false "your migrations conflict" would be worse than a missed one.