Rule catalog
Generated by migraguard rules --format markdown. All rules target PostgreSQL.
| Rule | Name | Default | Size-aware |
|---|---|---|---|
| MG001 | add-not-null-no-default |
error | no |
| MG002 | add-not-null-without-db-default |
warning | no |
| MG003 | remove-field |
warning | no |
| MG004 | rename-field |
error | no |
| MG005 | rename-model |
error | no |
| MG006 | alter-field-rewrite |
warning | yes |
| MG007 | add-index-not-concurrent |
warning | yes |
| MG008 | add-unique-not-concurrent |
warning | yes |
| MG009 | concurrent-in-atomic-migration |
error | no |
| MG010 | add-constraint-validated |
warning | yes |
| MG011 | add-foreign-key-validated |
warning | yes |
| MG012 | runpython-no-reverse |
warning | no |
| MG013 | runpython-not-elidable |
info | no |
| MG014 | runpython-unbatched |
warning | yes |
| MG015 | runsql-no-reverse |
warning | no |
| MG016 | delete-model |
warning | no |
| MG017 | data-and-schema-mixed |
warning | no |
| MG018 | separate-database-and-state |
warning | no |
| MG100 | migration-conflict |
error | no |
| MG101 | model-without-migration |
warning | no |
| MG102 | existing-migration-modified |
warning | no |
| MG103 | broken-dependency |
error | no |
| MG104 | unparseable-migration |
error | no |
| MG201 | sql-add-column-not-null |
error | no |
| MG202 | sql-create-index |
warning | yes |
| MG203 | sql-set-not-null |
warning | yes |
| MG204 | sql-constraint-validated |
warning | yes |
| MG205 | sql-alter-column-type |
warning | yes |
| MG206 | sql-drop-or-rename |
warning | no |
| MG207 | sql-add-unique |
warning | yes |
MG001
AddField is NOT NULL with no default (add-not-null-no-default, default severity: error)
Why: PostgreSQL cannot add a NOT NULL column without a value for existing rows, so the migration fails on any non-empty table. Even if the table is empty in development, it is not in production.
Safe pattern:
Add the column nullable, backfill in batches, then tighten it in a later deploy:
# 1st migration: schema only
migrations.AddField("<Model>", "<field>", models.<Type>(null=True))
# 2nd migration: batched RunPython backfill (see MG014)
# 3rd migration: constrain once every row has a value
migrations.AlterField("<Model>", "<field>", models.<Type>(default=...))
On Django 5.0+ a single step is also safe: models.<Type>(db_default=...).
MG002
AddField is NOT NULL with a Python-side default only (add-not-null-without-db-default, default severity: warning)
Why: Django drops the database default right after adding the column, so servers still running the old code (which does not know the column) fail every INSERT with a NOT NULL violation during a rolling deploy. Callable defaults are evaluated once and have the same problem.
Safe pattern:
Use a database-level default (Django 5.0+) so old code keeps working:
migrations.AddField(
"<Model>", "<field>",
models.<Type>(default=<value>, db_default=<value>),
)
On older Django add the field with null=True, backfill, then AlterField to NOT NULL.
MG003
RemoveField drops a column that running code may still read (remove-field, default severity: warning)
Why: During a rolling deploy old application servers still SELECT and INSERT the column. Dropping it in the same release as the code change makes those requests fail.
Safe pattern:
Two-phase removal:
1. Release N: stop reading and writing "<field>" in code. Make the field
null=True (or give it db_default) so old and new code both work.
2. Release N+1 (after all servers run release N): ship the RemoveField.
MG004
RenameField breaks code that is still running (rename-field, default severity: error)
Why: RENAME COLUMN is instant, but servers running the previous release keep using the old column name until the deploy finishes.
Safe pattern:
Expand/contract instead of renaming:
1. Add the new column and write to both (or copy with a trigger/backfill).
2. Switch reads to the new column.
3. Remove the old column in a later release.
If a short outage is acceptable, ignore this finding with # migraguard: ignore[MG004]
MG005
RenameModel renames a table that old code still uses (rename-model, default severity: error)
Why: The table (and its sequences, indexes, FKs) is renamed atomically, so old servers immediately hit relation does not exist.
Safe pattern:
Keep the physical table name and rename only in Python:
class <Model>(models.Model):
class Meta:
db_table = "<old_table_name>"
or create the new model, dual-write, copy data and drop the old one over several releases.
MG006
AlterField changes the column in a way that rewrites or scans the table (alter-field-rewrite, default severity: warning)
Why: Changing the column type, narrowing a varchar, changing numeric precision or adding NOT NULL takes an ACCESS EXCLUSIVE lock while PostgreSQL rewrites or scans the whole table.
Safe pattern:
For type changes and narrowing add a new column, dual-write, backfill in batches and swap in a later release.
For SET NOT NULL, validate a CHECK constraint first so PostgreSQL 12+ can skip the scan:
migrations.RunSQL(
'ALTER TABLE "<table>" ADD CONSTRAINT "<field>_not_null" CHECK ("<field>" IS NOT NULL) NOT VALID',
reverse_sql='ALTER TABLE "<table>" DROP CONSTRAINT "<field>_not_null"',
),
migrations.RunSQL('ALTER TABLE "<table>" VALIDATE CONSTRAINT "<field>_not_null"', migrations.RunSQL.noop),
migrations.RunSQL( # no scan now; wrap in SeparateDatabaseAndState to update Django's state
'ALTER TABLE "<table>" ALTER COLUMN "<field>" SET NOT NULL',
reverse_sql='ALTER TABLE "<table>" ALTER COLUMN "<field>" DROP NOT NULL',
),
MG007
Index is created without CONCURRENTLY (add-index-not-concurrent, default severity: warning)
Why: CREATE INDEX blocks all writes to the table for the duration of the build.
Safe pattern:
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently("<Model>", models.Index(fields=["<field>"], name="<name>")),
]
MG008
Unique constraint/index is built while holding a write lock (add-unique-not-concurrent, default severity: warning)
Why: unique=True, unique_together and UniqueConstraint build a unique index non-concurrently.
Safe pattern:
Build the index concurrently, then attach it as a constraint (instant):
atomic = False
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunSQL(
'CREATE UNIQUE INDEX CONCURRENTLY "<name>" ON "<table>" ("<field>")',
reverse_sql='DROP INDEX CONCURRENTLY "<name>"',
)],
state_operations=[migrations.AddConstraint("<Model>",
models.UniqueConstraint(fields=["<field>"], name="<name>"))],
),
]
MG009
Concurrent operation inside an atomic migration (concurrent-in-atomic-migration, default severity: error)
Why: CREATE/DROP INDEX CONCURRENTLY cannot run inside a transaction. The migration crashes when applied.
Safe pattern:
Set atomic = False on the migration class and keep it to the concurrent operations:
class Migration(migrations.Migration):
atomic = False
MG010
Constraint is validated while holding a lock (add-constraint-validated, default severity: warning)
Why: ADD CONSTRAINT ... CHECK validates every row under an ACCESS EXCLUSIVE lock.
Safe pattern:
Add the constraint NOT VALID, then validate it in a separate step (only a light lock):
from django.contrib.postgres.operations import AddConstraintNotValid, ValidateConstraint
operations = [
AddConstraintNotValid("<Model>", models.CheckConstraint(condition=..., name="<name>")),
ValidateConstraint("<Model>", name="<name>"),
]
MG011
Foreign key is added and validated under a lock (add-foreign-key-validated, default severity: warning)
Why: Adding a ForeignKey adds the constraint (validating all rows, locking both tables) and creates a non-concurrent index on the new column.
Safe pattern:
Add the column with db_constraint=False, create the index concurrently, then add the
constraint NOT VALID and VALIDATE it:
migrations.AddField("<Model>", "<field>", models.ForeignKey(..., null=True, db_constraint=False, db_index=False))
# AddIndexConcurrently(...) (atomic = False migration)
migrations.RunSQL(
'ALTER TABLE "<table>" ADD CONSTRAINT "<fk_name>" FOREIGN KEY ("<field>_id") '
'REFERENCES "<target>" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID',
reverse_sql='ALTER TABLE "<table>" DROP CONSTRAINT "<fk_name>"',
)
migrations.RunSQL('ALTER TABLE "<table>" VALIDATE CONSTRAINT "<fk_name>"', migrations.RunSQL.noop)
MG012
RunPython has no reverse_code (runpython-no-reverse, default severity: warning)
Why: Without reverse_code the migration cannot be rolled back, which removes your escape hatch when a deploy goes wrong.
Safe pattern:
Provide a reverse function, or migrations.RunPython.noop when undoing is a no-op:
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop)
MG013
RunPython is not marked elidable (runpython-not-elidable, default severity: info)
Why: Data migrations that are only relevant to old data should be elidable so squashmigrations can drop them.
Safe pattern:
migrations.RunPython(forwards, reverse_code=..., elidable=True)
MG014
RunPython iterates a whole queryset without batching (runpython-unbatched, default severity: warning)
Why: Looping over Model.objects.all() loads and saves every row inside one transaction. It holds row locks for the whole run and can exhaust memory.
Safe pattern:
def forwards(apps, schema_editor):
Model = apps.get_model("<app>", "<Model>")
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.
MG015
RunSQL has no reverse_sql (runsql-no-reverse, default severity: warning)
Why: The migration cannot be rolled back.
Safe pattern:
migrations.RunSQL(sql="...", reverse_sql="...") # or migrations.RunSQL.noop
MG016
DeleteModel drops a table that old code may still use (delete-model, default severity: warning)
Why: Old servers still query the table until the deploy completes, and the drop cannot be undone.
Safe pattern:
Release N: remove all usages of <Model> from code (keep the model, make writes stop).
Release N+1: ship DeleteModel. Consider renaming the table to "_deprecated" first to be able to recover it.
MG017
Data migration mixed with schema changes in one atomic migration (data-and-schema-mixed, default severity: warning)
Why: PostgreSQL can fail with 'cannot ALTER TABLE because it has pending trigger events', and the schema locks are held for as long as the data step runs.
Safe pattern:
Split into separate migrations: schema, then data (RunPython), then schema cleanup.
MG018
SeparateDatabaseAndState needs manual review (separate-database-and-state, default severity: warning)
Why: The database and Django's model state are changed independently. A mismatch is silent until runtime.
Safe pattern:
Check that database_operations and state_operations describe the same end state, run
makemigrations --check afterwards, and keep each such migration to a single purpose.
MG100
App has more than one leaf migration (migration-conflict, default severity: error)
Why: Two branches added migrations on the same parent. Django refuses to migrate until they are merged.
Safe pattern:
python manage.py makemigrations --merge
MG101
Model has no migration (model-without-migration, default severity: warning)
Why: A model class exists in models.py but no migration creates it (heuristic: field changes are not detected).
Safe pattern:
python manage.py makemigrations <app>
MG102
A migration that already exists on the base ref was modified (existing-migration-modified, default severity: warning)
Why: Edits to a migration that has been applied elsewhere are never re-run and make environments diverge.
Safe pattern:
Revert the edit and add a new migration instead.
MG103
Migration depends on a migration that does not exist (broken-dependency, default severity: error)
Why: The dependency names a migration of a scanned app that is not on disk.
Safe pattern:
Fix the name in `dependencies` or restore the missing migration file.
MG104
Migration file could not be analysed (unparseable-migration, default severity: error)
Why: The file has a syntax error or no Migration class with a literal operations list.
Safe pattern:
Fix the syntax error; keep `operations` a literal list so it can be analysed statically.
MG201
SQL: ADD COLUMN ... NOT NULL without DEFAULT (sql-add-column-not-null, default severity: error)
Why: Generated SQL adds a NOT NULL column without a default; it fails on non-empty tables.
Safe pattern:
See MG001 for the nullable -> backfill -> constrain pattern, or use db_default.
MG202
SQL: CREATE INDEX without CONCURRENTLY (sql-create-index, default severity: warning)
Why: Generated SQL builds an index that blocks writes.
Safe pattern:
See MG007: AddIndexConcurrently in a non-atomic migration.
MG203
SQL: ALTER COLUMN ... SET NOT NULL (sql-set-not-null, default severity: warning)
Why: Generated SQL scans the whole table under an ACCESS EXCLUSIVE lock.
Safe pattern:
See MG006: validate a CHECK (col IS NOT NULL) NOT VALID constraint first.
MG204
SQL: ADD CONSTRAINT (FOREIGN KEY/CHECK) without NOT VALID (sql-constraint-validated, default severity: warning)
Why: Generated SQL validates the constraint while holding locks.
Safe pattern:
See MG010 and MG011: add NOT VALID and VALIDATE CONSTRAINT separately.
MG205
SQL: ALTER COLUMN ... TYPE (sql-alter-column-type, default severity: warning)
Why: Generated SQL may rewrite the table under an ACCESS EXCLUSIVE lock.
Safe pattern:
See MG006: add a new column, dual-write, backfill and swap.
MG206
SQL: DROP COLUMN/TABLE or RENAME (sql-drop-or-rename, default severity: warning)
Why: Generated SQL removes or renames an object that running code may still use.
Safe pattern:
See MG003, MG004, MG005 and MG016 for the expand/contract alternatives.
MG207
SQL: ADD CONSTRAINT ... UNIQUE (sql-add-unique, default severity: warning)
Why: Generated SQL builds a unique index under a write lock.
Safe pattern:
See MG008: CREATE UNIQUE INDEX CONCURRENTLY then attach it with SeparateDatabaseAndState.