Skip to content

Detected Patterns

This page shows exactly what patterns django-safe-migrations detects, using real migration examples from our test suite. Each example demonstrates an unsafe pattern, explains why it's dangerous, and shows the safe alternative.

Quick Reference

Pattern Rule Severity Risk
NOT NULL without default SM001 ERROR Table lock
Drop column SM002 WARNING Application errors
Drop table SM003 WARNING Application errors
Non-concurrent index SM010 ERROR Table lock
RunSQL without reverse SM007 WARNING Irreversible
Enum in transaction SM012 ERROR Transaction fails
Unique constraint SM009 ERROR Table lock
RunPython without reverse SM016 INFO Irreversible
32-bit primary key SM028 WARNING Scalability
Non-concurrent index removal SM030 ERROR Table lock
NOT NULL with Python default SM033 WARNING Table rewrite
Missing lock_timeout SM035 INFO Blocking

NOT NULL Without Default

Rule: SM001 | Severity: ERROR

The Unsafe Pattern

# testapp/migrations/0002_unsafe_not_null.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0001_initial"),
    ]

    operations = [
        migrations.AddField(
            model_name="user",
            name="email",
            field=models.CharField(max_length=255),  # NOT NULL, no default!
        ),
    ]

Tool Output

ERROR [SM001] testapp/migrations/0002_unsafe_not_null.py:17
  Adding NOT NULL field 'email' to 'user' without a default value will lock the table
  Operation: AddField(user.email)

  Suggestion:
    Safe pattern for adding NOT NULL field:

    1. Migration 1 - Add field as nullable:
       migrations.AddField(
           model_name='user',
           name='email',
           field=models.CharField(max_length=255, null=True),
       )

    2. Data migration - Backfill existing rows in batches

    3. Migration 3 - Add NOT NULL constraint:
       migrations.AlterField(
           model_name='user',
           name='email',
           field=models.CharField(max_length=255, null=False),
       )

Why It's Dangerous

When you add a NOT NULL column without a default:

  1. PostgreSQL: Acquires ACCESS EXCLUSIVE lock on the table
  2. MySQL: May rewrite the entire table depending on version
  3. During rewrite: All other queries wait (reads AND writes)
  4. On large tables: Can take minutes to hours

The Safe Pattern

# Migration 1: Add nullable field
migrations.AddField(
    model_name="user",
    name="email",
    field=models.CharField(max_length=255, null=True),
)

# Migration 2: Backfill data
def backfill_email(apps, schema_editor):
    User = apps.get_model('testapp', 'User')
    User.objects.filter(email__isnull=True).update(
        email='placeholder@example.com'
    )

migrations.RunPython(backfill_email, migrations.RunPython.noop)

# Migration 3: Add NOT NULL constraint
migrations.AlterField(
    model_name="user",
    name="email",
    field=models.CharField(max_length=255),
)

Drop Column

Rule: SM002 | Severity: WARNING

The Unsafe Pattern

# testapp/migrations/0005_drop_column.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0004_unsafe_index"),
    ]

    operations = [
        migrations.RemoveField(
            model_name="user",
            name="nickname",
        ),
    ]

Tool Output

WARNING [SM002] testapp/migrations/0005_drop_column.py:17
  Dropping column 'nickname' from 'user' - ensure all code references have been removed first
  Operation: RemoveField(user.nickname)

  Suggestion:
    Safe column removal pattern:

    1. Deploy code that doesn't reference the column
    2. Wait for all servers to have new code
    3. Then run migration to drop column

Why It's Dangerous

During rolling deployments:

Time    Event
0:00    Migration runs - column "nickname" deleted
0:01    Server A still running OLD code
0:02    Request hits Server A
0:02    Query: SELECT nickname FROM user WHERE...
0:02    ERROR: column "nickname" does not exist
0:05    Server A finally gets new code

The Safe Pattern

  1. Deploy 1: Remove all code references to the column
  2. Wait: Ensure all servers have the new code
  3. Deploy 2: Run the migration to drop the column

Drop Table

Rule: SM003 | Severity: WARNING

The Unsafe Pattern

# testapp/migrations/0010_delete_model.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0009_run_python_no_reverse"),
    ]

    operations = [
        migrations.DeleteModel(
            name="Profile",
        ),
    ]

Tool Output

WARNING [SM003] testapp/migrations/0010_delete_model.py:17
  Dropping table 'profile' - ensure all code references have been removed first
  Operation: DeleteModel(Profile)

  Suggestion:
    Safe table removal pattern:

    1. Remove all code that references this model
    2. Deploy and verify no usage
    3. Then run migration to drop table

Why It's Dangerous

Same as column drops, but worse:

  • Entire model becomes inaccessible
  • Foreign keys from other tables may fail
  • Data loss is permanent

The Safe Pattern

  1. Deploy 1: Remove model from code, remove all FKs
  2. Wait: Verify no queries hit the table (logs, monitoring)
  3. Deploy 2: Drop the table

Non-Concurrent Index (PostgreSQL)

Rule: SM010 | Severity: ERROR

The Unsafe Pattern

# testapp/migrations/0004_unsafe_index.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0003_safe_nullable"),
    ]

    operations = [
        migrations.AddIndex(
            model_name="user",
            index=models.Index(fields=["email"], name="user_email_idx"),
        ),
    ]

Tool Output

ERROR [SM010] testapp/migrations/0004_unsafe_index.py:17
  Creating index without CONCURRENTLY on PostgreSQL will lock the table
  Operation: AddIndex(user, user_email_idx)

  Suggestion:
    Use AddIndexConcurrently from django.contrib.postgres.operations:

    from django.contrib.postgres.operations import AddIndexConcurrently

    class Migration(migrations.Migration):
        atomic = False  # Required for concurrent operations

        operations = [
            AddIndexConcurrently(
                model_name='user',
                index=models.Index(fields=['email'], name='user_email_idx'),
            ),
        ]

Why It's Dangerous

Standard CREATE INDEX:

  • Acquires SHARE lock on the table
  • Blocks all INSERT, UPDATE, DELETE operations
  • On large tables: minutes to hours of downtime

The Safe Pattern

from django.contrib.postgres.operations import AddIndexConcurrently

class Migration(migrations.Migration):
    atomic = False  # REQUIRED!

    operations = [
        AddIndexConcurrently(
            model_name="user",
            index=models.Index(fields=["email"], name="user_email_idx"),
        ),
    ]

RunSQL Without Reverse

Rule: SM007 | Severity: WARNING

The Unsafe Pattern

# testapp/migrations/0006_run_sql_no_reverse.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0005_drop_column"),
    ]

    operations = [
        migrations.RunSQL(
            sql="CREATE INDEX idx_user_created ON testapp_user (id)",
            # Missing reverse_sql!
        ),
    ]

Tool Output

WARNING [SM007] testapp/migrations/0006_run_sql_no_reverse.py:17
  RunSQL without reverse_sql cannot be rolled back
  Operation: RunSQL(CREATE INDEX idx_user_created...)

  Suggestion:
    Add reverse_sql to make the migration reversible:

    migrations.RunSQL(
        sql="CREATE INDEX idx_user_created ON testapp_user (id)",
        reverse_sql="DROP INDEX IF EXISTS idx_user_created",
    )

    If the operation is intentionally irreversible, use:
    reverse_sql=migrations.RunSQL.noop

Why It's Dangerous

  • Cannot rollback if deployment fails
  • Blocks automated rollback systems
  • Makes debugging harder

The Safe Pattern

migrations.RunSQL(
    sql="CREATE INDEX idx_user_created ON testapp_user (id)",
    reverse_sql="DROP INDEX IF EXISTS idx_user_created",
)

Enum Value in Transaction (PostgreSQL)

Rule: SM012 | Severity: ERROR

The Unsafe Pattern

# In an atomic migration (default)
class Migration(migrations.Migration):
    # atomic = True is the default

    operations = [
        migrations.RunSQL(
            sql="ALTER TYPE status_enum ADD VALUE 'pending'",
        ),
    ]

Tool Output

ERROR [SM012] testapp/migrations/0007_enum_in_transaction.py:29
  ALTER TYPE ADD VALUE cannot run inside a transaction block in PostgreSQL
  Operation: RunSQL(ALTER TYPE...ADD VALUE...)

  Suggestion:
    Set atomic = False on the migration to run outside transaction:

    class Migration(migrations.Migration):
        atomic = False  # Required for ALTER TYPE ADD VALUE

        operations = [
            migrations.RunSQL(
                sql="ALTER TYPE status_enum ADD VALUE 'pending'",
                reverse_sql=migrations.RunSQL.noop,
            ),
        ]

Why It's Dangerous

PostgreSQL does not allow ALTER TYPE ... ADD VALUE inside a transaction. The migration will fail with:

ERROR: ALTER TYPE ... ADD VALUE cannot run inside a transaction block

The Safe Pattern

class Migration(migrations.Migration):
    atomic = False  # REQUIRED!

    operations = [
        migrations.RunSQL(
            sql="ALTER TYPE status_enum ADD VALUE 'pending'",
            reverse_sql=migrations.RunSQL.noop,  # Cannot remove enum values
        ),
    ]

Unique Constraint

Rule: SM009 | Severity: ERROR

The Unsafe Pattern

# testapp/migrations/0008_unique_constraint.py

class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0007_enum_in_transaction"),
    ]

    operations = [
        migrations.AddConstraint(
            model_name="user",
            constraint=models.UniqueConstraint(
                fields=["email"],
                name="unique_user_email",
            ),
        ),
    ]

Tool Output

ERROR [SM009] testapp/migrations/0008_unique_constraint.py:17
  Adding unique constraint requires full table scan to validate existing data
  Operation: AddConstraint(user, unique_user_email)

  Suggestion:
    For PostgreSQL, use AddConstraintNotValid + separate validation:

    1. Add constraint as NOT VALID (doesn't scan table):
       ALTER TABLE user ADD CONSTRAINT unique_user_email
       UNIQUE (email) NOT VALID;

    2. Validate in a separate migration:
       ALTER TABLE user VALIDATE CONSTRAINT unique_user_email;

Why It's Dangerous

Adding a unique constraint:

  1. Scans the entire table to check for duplicates
  2. Holds locks during the scan
  3. On large tables: significant downtime

The Safe Pattern (PostgreSQL)

class Migration(migrations.Migration):
    atomic = False

    operations = [
        # Step 1: Create unique index concurrently
        AddIndexConcurrently(
            model_name="user",
            index=models.Index(
                fields=["email"],
                name="unique_user_email_idx",
            ),
        ),
        # Step 2: Add constraint using the index
        migrations.RunSQL(
            sql="""
            ALTER TABLE testapp_user
            ADD CONSTRAINT unique_user_email
            UNIQUE USING INDEX unique_user_email_idx;
            """,
            reverse_sql="ALTER TABLE testapp_user DROP CONSTRAINT unique_user_email;",
        ),
    ]

RunPython Without Reverse

Rule: SM016 | Severity: INFO

The Unsafe Pattern

# testapp/migrations/0009_run_python_no_reverse.py

def populate_data(apps, schema_editor):
    """Populate some data."""
    User = apps.get_model('testapp', 'User')
    User.objects.create(name="Default User")


class Migration(migrations.Migration):
    dependencies = [
        ("testapp", "0008_unique_constraint"),
    ]

    operations = [
        migrations.RunPython(
            populate_data,
            # Missing reverse_code!
        ),
    ]

Tool Output

INFO [SM016] testapp/migrations/0009_run_python_no_reverse.py:22
  RunPython without reverse_code cannot be rolled back
  Operation: RunPython(populate_data)

  Suggestion:
    Add reverse_code to make the migration reversible:

    def reverse_populate(apps, schema_editor):
        User = apps.get_model('testapp', 'User')
        User.objects.filter(name="Default User").delete()

    migrations.RunPython(
        populate_data,
        reverse_code=reverse_populate,
    )

    If intentionally irreversible, use:
    reverse_code=migrations.RunPython.noop

Why It Matters

  • Prevents rollback in case of failed deployment
  • Makes testing migrations harder
  • Can block CI/CD pipelines that test rollbacks

The Safe Pattern

def populate_data(apps, schema_editor):
    User = apps.get_model('testapp', 'User')
    User.objects.create(name="Default User")


def reverse_populate(apps, schema_editor):
    User = apps.get_model('testapp', 'User')
    User.objects.filter(name="Default User").delete()


class Migration(migrations.Migration):
    operations = [
        migrations.RunPython(
            populate_data,
            reverse_code=reverse_populate,
        ),
    ]

32-bit Primary Key

Rule: SM028 | Severity: WARNING

The Unsafe Pattern

# testapp/migrations/0021_autofield_primary_key.py

class Migration(migrations.Migration):
    operations = [
        migrations.CreateModel(
            name="LegacyItem",
            fields=[
                (
                    "id",
                    models.AutoField(primary_key=True, serialize=False),
                ),
                ("name", models.CharField(max_length=100)),
            ],
        ),
    ]

Tool Output

WARNING [SM028] testapp/migrations/0021_autofield_primary_key.py
  Prefer BigAutoField over 32-bit AutoField for primary keys.
  32-bit integers max out at ~2.1 billion rows.

Why It's Dangerous

Using a 32-bit AutoField limits your table to approximately 2.1 billion rows. While that sounds like a lot, high-write tables (logs, events, orders) can hit this ceiling, causing inserts to fail with integer overflow errors. Migrating a large table from AutoField to BigAutoField later requires a full table rewrite, which means extended downtime.

The Safe Pattern

# settings.py
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

Non-Concurrent Index Removal (PostgreSQL)

Rule: SM030 | Severity: ERROR

The Unsafe Pattern

# testapp/migrations/0023_remove_index.py

class Migration(migrations.Migration):
    operations = [
        migrations.RemoveIndex(
            model_name='user',
            name='testapp_user_uname_idx',
        ),
    ]

Tool Output

ERROR [SM030] testapp/migrations/0023_remove_index.py
  Index removal without CONCURRENTLY will lock the table on PostgreSQL.

Why It's Dangerous

Standard DROP INDEX:

  • Acquires ACCESS EXCLUSIVE lock on the table
  • Blocks all reads and writes while the index is being removed
  • On large tables with many concurrent connections, this can cause request queuing and timeouts

The Safe Pattern

from django.contrib.postgres.operations import RemoveIndexConcurrently

class Migration(migrations.Migration):
    atomic = False  # REQUIRED for concurrent operations

    operations = [
        RemoveIndexConcurrently(
            model_name='user',
            name='testapp_user_uname_idx',
        ),
    ]

NOT NULL Field with Python Default

Rule: SM033 | Severity: WARNING

The Unsafe Pattern

# testapp/migrations/0024_field_with_default.py

class Migration(migrations.Migration):
    operations = [
        migrations.AddField(
            model_name='user',
            name='status',
            field=models.CharField(max_length=20, default='active'),
        ),
    ]

Tool Output

WARNING [SM033] testapp/migrations/0024_field_with_default.py
  Adding NOT NULL field with Python default rewrites all existing rows.

Why It's Dangerous

When you add a NOT NULL column with a Python-level default:

  1. Django generates SQL that adds the column with a server-side DEFAULT
  2. PostgreSQL then rewrites every existing row to fill in the default value
  3. This acquires an ACCESS EXCLUSIVE lock for the duration of the rewrite
  4. On large tables, the rewrite can take minutes to hours, blocking all queries

The Safe Pattern

# Django 5.0+: Use db_default to set the default at the database level
# without rewriting existing rows on supported databases
migrations.AddField(
    model_name='user',
    name='status',
    field=models.CharField(max_length=20, db_default='active'),
)

Missing lock_timeout in RunSQL

Rule: SM035 | Severity: INFO

The Unsafe Pattern

# testapp/migrations/0025_run_sql_no_lock_timeout.py

class Migration(migrations.Migration):
    operations = [
        migrations.RunSQL(
            sql="ALTER TABLE testapp_user ADD COLUMN temp_col INTEGER",
            reverse_sql="ALTER TABLE testapp_user DROP COLUMN temp_col",
        ),
    ]

Tool Output

INFO [SM035] testapp/migrations/0025_run_sql_no_lock_timeout.py
  RunSQL with DDL should set lock_timeout to avoid blocking.

Why It Matters

DDL statements like ALTER TABLE acquire locks that can block other queries. Without a lock_timeout, if the lock cannot be acquired immediately (because of long-running queries holding conflicting locks), the migration will wait indefinitely, which in turn queues up all subsequent queries behind it, potentially causing a full outage.

The Safe Pattern

migrations.RunSQL(
    sql=[
        "SET lock_timeout = '5s';",
        "ALTER TABLE testapp_user ADD COLUMN temp_col INTEGER",
    ],
    reverse_sql="ALTER TABLE testapp_user DROP COLUMN temp_col",
)

Running the Examples

You can test these patterns yourself by running django-safe-migrations against our test suite:

# Clone the repository
git clone https://github.com/YasserShkeir/django-safe-migrations.git
cd django-safe-migrations

# Install in development mode
pip install -e ".[dev]"

# Run against test migrations
cd tests/test_project
python manage.py check_migrations testapp

You should see output detecting all the unsafe patterns documented above.

See Also