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:
- PostgreSQL: Acquires
ACCESS EXCLUSIVElock on the table - MySQL: May rewrite the entire table depending on version
- During rewrite: All other queries wait (reads AND writes)
- 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¶
- Deploy 1: Remove all code references to the column
- Wait: Ensure all servers have the new code
- 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¶
- Deploy 1: Remove model from code, remove all FKs
- Wait: Verify no queries hit the table (logs, monitoring)
- 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
SHARElock on the table - Blocks all
INSERT,UPDATE,DELETEoperations - 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:
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:
- Scans the entire table to check for duplicates
- Holds locks during the scan
- 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¶
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 EXCLUSIVElock 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:
- Django generates SQL that adds the column with a server-side DEFAULT
- PostgreSQL then rewrites every existing row to fill in the default value
- This acquires an
ACCESS EXCLUSIVElock for the duration of the rewrite - 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¶
- Rules Reference - Detailed documentation for each rule
- Safe Patterns - Comprehensive guide to safe migration patterns
- Configuration - How to suppress or configure rules