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.