Rules Reference¶
Django Safe Migrations includes rules to detect common unsafe migration patterns.
Rule Severity Levels¶
| Level | Meaning |
|---|---|
| ERROR | Will likely break production or cause significant downtime |
| WARNING | May cause issues depending on your deployment strategy |
| INFO | Best practice recommendation |
SM001: NOT NULL Without Default¶
Severity: ERROR
Databases: All
What it detects¶
Adding a NOT NULL column without a default value:
# UNSAFE
migrations.AddField(
model_name='user',
name='email',
field=models.EmailField(), # NOT NULL, no default
)
Why it's dangerous¶
On PostgreSQL (and most databases), adding a NOT NULL column without a default:
- Takes an
ACCESS EXCLUSIVElock on the table - Rewrites every row to add the new column
- Blocks all reads and writes until complete
For large tables, this can take minutes to hours.
Safe pattern¶
# SAFE: Three-step process
# Migration 1: Add nullable column
migrations.AddField(
model_name='user',
name='email',
field=models.EmailField(null=True),
)
# Migration 2: Backfill existing rows
def backfill(apps, schema_editor):
User = apps.get_model('myapp', 'User')
batch_size = 1000
while User.objects.filter(email__isnull=True).exists():
ids = list(User.objects.filter(email__isnull=True)
.values_list('id', flat=True)[:batch_size])
User.objects.filter(id__in=ids).update(email='default@example.com')
migrations.RunPython(backfill, migrations.RunPython.noop)
# Migration 3: Add NOT NULL constraint
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(),
)
SM002: Unsafe Column Drop¶
Severity: WARNING
Databases: All
What it detects¶
Dropping a column that may still be referenced by running code:
Why it's dangerous¶
During a rolling deployment:
- New code runs migration, drops column
- Old code instances still running try to read column
- Application errors!
Safe pattern¶
Use the expand/contract pattern:
- Release 1: Remove all code that reads/writes the column
- Release 2: Deploy, verify no queries reference the column
- Release 3: Drop the column
SM003: Unsafe Table Drop¶
Severity: WARNING
Databases: All
What it detects¶
Dropping a table (model) that may still be referenced:
Why it's dangerous¶
Same as SM002, but for entire tables. Also:
- Foreign keys from other tables may cause constraint violations
- Raw SQL queries may still reference the table
Safe pattern¶
- Remove all code references to the model
- Remove foreign keys in separate migrations
- Deploy and verify no queries reference the table
- Drop the table in a later release
SM010: Non-Concurrent Index Creation¶
Severity: ERROR
Databases: PostgreSQL only
What it detects¶
Creating an index without using CONCURRENTLY:
# UNSAFE on PostgreSQL
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['email'], name='user_email_idx'),
)
Why it's dangerous¶
Standard index creation takes a SHARE lock that blocks:
INSERT,UPDATE,DELETEoperations- Other
ALTER TABLEoperations
For large tables, this can take minutes to hours of write downtime.
Safe pattern¶
# SAFE: Use concurrent index
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False # Required for CONCURRENTLY
operations = [
AddIndexConcurrently(
model_name='user',
index=models.Index(fields=['email'], name='user_email_idx'),
),
]
Note
atomic = False is required because CREATE INDEX CONCURRENTLY cannot run inside a transaction.
SM011: Non-Concurrent Unique Constraint¶
Severity: ERROR
Databases: PostgreSQL only
What it detects¶
Adding a unique constraint without using a concurrent index:
# UNSAFE on PostgreSQL
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email'],
name='unique_user_email',
),
)
Why it's dangerous¶
PostgreSQL implements unique constraints using indexes. Adding one:
- Creates a unique index (blocking writes)
- Validates all existing rows (more blocking)
Safe pattern¶
# SAFE: Two-step process
# Migration 1: Create unique index concurrently
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
model_name='user',
index=models.Index(
fields=['email'],
name='unique_user_email_idx',
),
),
]
# Migration 2: Add constraint using existing index
migrations.RunSQL(
sql='''
ALTER TABLE myapp_user
ADD CONSTRAINT unique_user_email
UNIQUE USING INDEX unique_user_email_idx
''',
reverse_sql='ALTER TABLE myapp_user DROP CONSTRAINT unique_user_email',
)
SM004: Alter Column Type¶
Severity: WARNING
Databases: All (especially PostgreSQL)
What it detects¶
Changing a column's type via AlterField:
# WARNING
migrations.AlterField(
model_name='product',
name='price',
field=models.DecimalField(max_digits=10, decimal_places=2),
)
Why it's dangerous¶
Changing column types often requires:
- Rewriting every row in the table
- Taking an
ACCESS EXCLUSIVElock - Blocking all reads and writes
Even seemingly safe changes (like Integer → BigInteger) can trigger full table rewrites.
Safe pattern¶
# SAFE: Expand/Contract pattern
# Migration 1: Add new column
migrations.AddField(
model_name='product',
name='price_new',
field=models.DecimalField(max_digits=10, decimal_places=2, null=True),
)
# Migration 2: Copy data in batches
def copy_data(apps, schema_editor):
Product = apps.get_model('myapp', 'Product')
batch_size = 1000
for product in Product.objects.iterator(chunk_size=batch_size):
product.price_new = product.price
product.save(update_fields=['price_new'])
migrations.RunPython(copy_data, migrations.RunPython.noop)
# Migration 3: Switch application to use new column
# Migration 4: Drop old column
SM005: Foreign Key Validates Existing Rows¶
Severity: WARNING
Databases: PostgreSQL
What it detects¶
Adding a ForeignKey that validates existing rows:
# WARNING
migrations.AddField(
model_name='article',
name='author',
field=models.ForeignKey(
to='auth.User',
on_delete=models.CASCADE,
),
)
Why it's dangerous¶
Adding a FK constraint:
- Scans ALL existing rows to verify the constraint
- Takes a
SHARE ROW EXCLUSIVElock on both tables - Blocks writes on large tables for extended periods
Safe pattern¶
# SAFE: Add FK without constraint validation
# Migration 1: Add FK without database constraint
migrations.AddField(
model_name='article',
name='author',
field=models.ForeignKey(
to='auth.User',
on_delete=models.CASCADE,
db_constraint=False, # Skip constraint creation
),
)
# Migration 2: Add constraint with NOT VALID (PostgreSQL)
migrations.RunSQL(
sql='''
ALTER TABLE myapp_article
ADD CONSTRAINT article_author_fk
FOREIGN KEY (author_id) REFERENCES auth_user(id)
NOT VALID
''',
reverse_sql='ALTER TABLE myapp_article DROP CONSTRAINT article_author_fk',
)
# Migration 3: Validate constraint (doesn't block writes)
migrations.RunSQL(
sql='ALTER TABLE myapp_article VALIDATE CONSTRAINT article_author_fk',
reverse_sql=migrations.RunSQL.noop,
)
SM006: Rename Column¶
Severity: INFO
Databases: All
What it detects¶
Renaming a column:
Why it's flagged¶
During a rolling deployment:
- Migration renames column
- Old application instances still expect old column name
- Queries fail until all instances are updated
Safe pattern¶
For zero-downtime deployments:
# SAFE: Expand/Contract pattern
# Migration 1: Add new column
migrations.AddField(
model_name='user',
name='login',
field=models.CharField(max_length=150, null=True),
)
# Migration 2: Copy data
def copy_data(apps, schema_editor):
User = apps.get_model('myapp', 'User')
User.objects.update(login=F('username'))
migrations.RunPython(copy_data, migrations.RunPython.noop)
# Deploy: Update application to write to both columns, read from new
# Migration 3: Make NOT NULL, drop old column
SM007: RunSQL Without Reverse¶
Severity: WARNING
Databases: All
What it detects¶
RunSQL operations without reverse_sql:
Why it's dangerous¶
Without reverse_sql:
- Migration cannot be rolled back
- May leave database in inconsistent state on failure
- Breaks
migratecommand's ability to undo changes
Safe pattern¶
# SAFE: Always provide reverse_sql
migrations.RunSQL(
sql='CREATE INDEX idx_user_email ON users (email)',
reverse_sql='DROP INDEX idx_user_email',
)
# Or if the operation is intentionally irreversible:
migrations.RunSQL(
sql='CREATE INDEX idx_user_email ON users (email)',
reverse_sql=migrations.RunSQL.noop, # Explicitly mark as no-op
)
SM008: Large Data Migration¶
Severity: INFO
Databases: All
What it detects¶
RunPython data migrations:
# INFO
def update_all_users(apps, schema_editor):
User = apps.get_model('myapp', 'User')
for user in User.objects.all():
user.name = user.name.upper()
user.save()
migrations.RunPython(update_all_users, migrations.RunPython.noop)
Why it's flagged¶
Data migrations can be slow because:
- Loading all objects into memory
- Individual
save()calls (N+1 queries) - No batching or chunking
- Long-running transactions
Safe pattern¶
# SAFE: Batch processing with iterator
def update_all_users(apps, schema_editor):
User = apps.get_model('myapp', 'User')
batch_size = 1000
# Use iterator to avoid loading all objects
for user in User.objects.iterator(chunk_size=batch_size):
user.name = user.name.upper()
user.save(update_fields=['name'])
# Or use bulk_update for better performance:
def update_all_users_bulk(apps, schema_editor):
User = apps.get_model('myapp', 'User')
batch_size = 1000
users = User.objects.all()
for batch in chunked(users.iterator(), batch_size):
for user in batch:
user.name = user.name.upper()
User.objects.bulk_update(batch, ['name'])
SM012: Enum Add Value in Transaction¶
Severity: ERROR
Databases: PostgreSQL only
What it detects¶
Adding a value to an enum type inside a transaction:
Why it's dangerous¶
PostgreSQL does not allow ALTER TYPE ... ADD VALUE inside a transaction block. Running this migration will cause:
Safe pattern¶
# SAFE: Set atomic = False
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,
),
]
Note
Enum value additions cannot be easily reversed. Consider if you really need an enum, or if a regular VARCHAR would work better.
SM013: Alter VARCHAR Length¶
Severity: WARNING
Databases: PostgreSQL
What it detects¶
Decreasing the max_length of a CharField:
# WARNING - if max_length is being decreased
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(max_length=50), # Was max_length=100
)
Why it's dangerous¶
In PostgreSQL:
- Increasing max_length: Just a metadata change (safe)
- Decreasing max_length: Requires table rewrite + exclusive lock
The database must verify all existing data fits within the new length.
Safe pattern¶
# SAFE: Use a CHECK constraint instead
# Migration 1: Add CHECK constraint (doesn't rewrite table)
migrations.RunSQL(
sql='''
ALTER TABLE myapp_user
ADD CONSTRAINT check_username_length
CHECK (LENGTH(username) <= 50)
''',
reverse_sql='ALTER TABLE myapp_user DROP CONSTRAINT check_username_length',
)
# Verify all data fits, then optionally alter the column type
# during a maintenance window if needed
SM009: Adding Unique Constraint¶
Severity: ERROR
Databases: Non-PostgreSQL (on PostgreSQL, the more specific SM011 takes over)
What it detects¶
Adding a unique constraint to an existing table:
# UNSAFE
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email', 'tenant_id'],
name='unique_email_per_tenant',
),
)
Why it's dangerous¶
Adding a unique constraint requires:
- A full table scan to validate existing rows
- Creating a unique index (blocking writes on PostgreSQL)
For large tables, this can take minutes to hours.
Safe pattern¶
# SAFE: Create index concurrently first
# Migration 1: Create unique index concurrently
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
model_name='user',
index=models.Index(
fields=['email', 'tenant_id'],
name='unique_email_tenant_idx',
),
),
]
# Migration 2: Add constraint using existing index
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email', 'tenant_id'],
name='unique_email_per_tenant',
),
)
SM014: Rename Model¶
Severity: WARNING
Databases: All
What it detects¶
Renaming a model (which renames the database table):
Why it's dangerous¶
Renaming a model can cause:
- Foreign keys from other apps may reference the old table name
- Raw SQL queries using the table name will break
- Database-level permissions may be lost
- Indexes and constraints may need renaming
Safe pattern¶
# SAFE: Keep the old table name
class NewUser(models.Model):
# ... fields ...
class Meta:
db_table = 'olduser' # Keep the old table name
Or use the expand/contract pattern:
- Create a new model with the new name
- Copy data in a migration
- Update all foreign keys and references
- Remove the old model in a later release
SM015: Alter Unique Together (Deprecated)¶
Severity: WARNING
Databases: All
What it detects¶
Using the deprecated AlterUniqueTogether operation:
# WARNING - deprecated
migrations.AlterUniqueTogether(
name='user',
unique_together={('email', 'tenant_id')},
)
Why it's dangerous¶
unique_together is deprecated since Django 4.0. Using it:
- Still requires a table scan to validate uniqueness
- Doesn't support modern constraint features
- May be removed in future Django versions
Safe pattern¶
# SAFE: Use UniqueConstraint instead
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email', 'tenant_id'],
name='unique_email_tenant',
),
)
UniqueConstraint provides more features:
- Conditional uniqueness (
conditionparameter) - Partial indexes
- Better introspection support
SM016: RunPython Without Reverse¶
Severity: INFO
Databases: All
What it detects¶
RunPython operations without a reverse_code function:
Why it's dangerous¶
Without reverse_code, the migration cannot be rolled back. If something goes wrong:
- You cannot easily revert the migration
- Manual database fixes may be required
- Deployment rollbacks become risky
Safe pattern¶
# SAFE: Always provide reverse_code
def populate_defaults(apps, schema_editor):
Model = apps.get_model('app', 'Model')
Model.objects.filter(field__isnull=True).update(field='default')
def reverse_defaults(apps, schema_editor):
Model = apps.get_model('app', 'Model')
Model.objects.filter(field='default').update(field=None)
migrations.RunPython(
populate_defaults,
reverse_code=reverse_defaults,
)
# Or if reversal isn't needed:
migrations.RunPython(
populate_defaults,
reverse_code=migrations.RunPython.noop,
)
SM017: Adding Check Constraint¶
Severity: WARNING
Databases: PostgreSQL
What it detects¶
Adding a check constraint to an existing table:
# WARNING
migrations.AddConstraint(
model_name='order',
constraint=models.CheckConstraint(
condition=models.Q(amount__gte=0),
name='positive_amount',
),
)
Why it's dangerous¶
Adding a check constraint requires PostgreSQL to validate ALL existing rows against the constraint. For large tables:
- This can take a long time
- It blocks writes during validation
- May fail if existing data violates the constraint
Safe pattern¶
# SAFE: Add as NOT VALID first, then validate
# Migration 1: Add constraint as NOT VALID
migrations.RunSQL(
sql='''
ALTER TABLE myapp_order
ADD CONSTRAINT positive_amount
CHECK (amount >= 0)
NOT VALID;
''',
reverse_sql='ALTER TABLE myapp_order DROP CONSTRAINT positive_amount;',
)
# Migration 2: Validate in a separate step
migrations.RunSQL(
sql='ALTER TABLE myapp_order VALIDATE CONSTRAINT positive_amount;',
reverse_sql=migrations.RunSQL.noop,
)
The NOT VALID option adds the constraint without validating existing rows. The VALIDATE CONSTRAINT step:
- Only takes a
SHARE UPDATE EXCLUSIVElock (allows reads/writes) - Validates rows incrementally
- Can be run during normal operation
SM018: Concurrent Index in Atomic Migration¶
Severity: ERROR
Databases: PostgreSQL
What it detects¶
Using AddIndexConcurrently or RemoveIndexConcurrently in a migration without
atomic = False:
# ERROR
class Migration(migrations.Migration):
# Missing atomic = False!
operations = [
AddIndexConcurrently(
model_name='order',
index=models.Index(fields=['status'], name='order_status_idx'),
),
]
Why it's dangerous¶
PostgreSQL's CREATE INDEX CONCURRENTLY cannot run inside a transaction. If you
forget to set atomic = False, the migration will fail at runtime with:
Safe pattern¶
# SAFE: Set atomic = False
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
model_name='order',
index=models.Index(fields=['status'], name='order_status_idx'),
),
]
SM019: Reserved Keyword Column Name¶
Severity: INFO
Databases: All
What it detects¶
Using SQL reserved keywords as column names:
# INFO
migrations.AddField(
model_name='product',
name='order', # 'order' is a SQL keyword
field=models.IntegerField(),
)
Why it's problematic¶
While Django quotes identifiers, reserved keywords can cause issues with:
- Raw SQL queries
- Database tools and GUIs
- Third-party ORMs or reporting tools
- Future SQL standard compatibility
Common problematic names: order, user, group, select, table, index,
key, primary, foreign, check, constraint.
Safe pattern¶
# SAFE: Use descriptive, non-reserved names
migrations.AddField(
model_name='product',
name='sort_order', # Descriptive and not reserved
field=models.IntegerField(),
)
SM020: AlterField to NOT NULL Without Backfill¶
Severity: ERROR
Databases: All
What it detects¶
Changing a field from null=True to null=False without ensuring existing NULL
values are handled:
# ERROR
migrations.AlterField(
model_name='user',
name='email',
field=models.CharField(max_length=255, null=False), # Was null=True
)
Why it's dangerous¶
If the table contains rows with NULL values in this column, the migration will fail:
Even worse, in some databases this operation locks the table while checking all rows.
Safe pattern¶
# SAFE: Backfill NULLs first, then alter
# Migration 1: Backfill NULL values
def backfill_emails(apps, schema_editor):
User = apps.get_model('myapp', 'User')
User.objects.filter(email__isnull=True).update(email='unknown@example.com')
migrations.RunPython(backfill_emails, migrations.RunPython.noop)
# Migration 2: Now safe to make NOT NULL
migrations.AlterField(
model_name='user',
name='email',
field=models.CharField(max_length=255, null=False),
)
SM021: Adding UNIQUE via AlterField¶
Severity: ERROR
Databases: PostgreSQL
What it detects¶
Adding a UNIQUE constraint via AlterField:
# ERROR
migrations.AlterField(
model_name='user',
name='email',
field=models.CharField(max_length=255, unique=True), # Adding unique=True
)
Why it's dangerous¶
- Requires scanning ALL rows to check for duplicates
- Locks the table during the check
- Fails if duplicates exist
- On large tables, can cause significant downtime
Safe pattern¶
# SAFE: Use concurrent index (PostgreSQL)
# Migration 1: Create unique index concurrently
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
model_name='user',
index=models.Index(
fields=['email'],
name='user_email_unique_idx',
condition=None,
),
),
]
# Migration 2: Add constraint using the index
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email'],
name='user_email_unique',
),
)
SM022: Expensive Default Callable¶
Severity: WARNING
Databases: All
What it detects¶
Using potentially expensive callables as field defaults:
# WARNING
migrations.AddField(
model_name='event',
name='created_at',
field=models.DateTimeField(default=datetime.now), # Called for each row!
)
Why it's problematic¶
When adding a column with a default, some databases:
- Call the default function for EVERY existing row
- For
datetime.now(), this means N function calls - For
uuid.uuid4(), this generates N UUIDs synchronously - Can significantly slow down migrations on large tables
Safe pattern¶
# SAFE: Use database-level defaults or backfill
# Option 1: Use auto_now_add (handled at ORM level)
field=models.DateTimeField(auto_now_add=True, null=True)
# Option 2: Use database default
migrations.RunSQL(
sql="ALTER TABLE myapp_event ADD COLUMN created_at TIMESTAMP DEFAULT NOW()",
reverse_sql="ALTER TABLE myapp_event DROP COLUMN created_at",
)
# Option 3: Add nullable, backfill, then make required
# Migration 1: Add as nullable
migrations.AddField(
model_name='event',
name='created_at',
field=models.DateTimeField(null=True),
)
# Migration 2: Backfill with batch updates
# Migration 3: Make NOT NULL
SM023: Adding ManyToMany Field¶
Severity: INFO
Databases: All
What it detects¶
Adding a ManyToMany field:
# INFO
migrations.AddField(
model_name='article',
name='tags',
field=models.ManyToManyField(to='blog.Tag'),
)
Why it's notable¶
Adding a ManyToMany field:
- Creates a new junction table (e.g.,
article_tags) - The table creation itself is generally safe
- However, be aware of subsequent operations that populate this table
- Bulk inserts into the junction table can be slow
This is INFO severity because it's usually safe, but you should be aware of the implications.
Best practices¶
# Consider: Add the field, but populate data carefully
# The migration (safe):
migrations.AddField(
model_name='article',
name='tags',
field=models.ManyToManyField(to='blog.Tag'),
)
# When populating, use bulk operations:
def populate_tags(apps, schema_editor):
Article = apps.get_model('blog', 'Article')
Tag = apps.get_model('blog', 'Tag')
through_model = Article.tags.through
# Bulk create relationships
relations = [
through_model(article_id=a.id, tag_id=t.id)
for a, t in compute_relationships()
]
through_model.objects.bulk_create(relations, batch_size=1000)
SM024: SQL Injection Pattern in RunSQL¶
Severity: ERROR
Databases: All
What it detects¶
Potential SQL injection patterns in RunSQL operations:
# ERROR
table_name = "users" # Could come from untrusted source
migrations.RunSQL(
sql=f"DROP TABLE {table_name}", # String formatting = injection risk
)
# Also detected:
migrations.RunSQL(
sql="SELECT * FROM users WHERE id = %s" % user_id, # % formatting
)
Why it's dangerous¶
While migrations typically run in trusted environments, SQL injection patterns in migrations can:
- Be accidentally triggered with wrong data
- Set bad precedents for application code
- Cause issues if migration code is reused
- Be exploited if migration parameters come from external sources
Safe pattern¶
# SAFE: Use hardcoded SQL or Django's schema editor
# Option 1: Hardcoded SQL (when you control the values)
migrations.RunSQL(
sql="CREATE INDEX idx_users_email ON users(email)",
reverse_sql="DROP INDEX idx_users_email",
)
# Option 2: Use schema editor for dynamic operations
def create_index(apps, schema_editor):
model = apps.get_model('myapp', 'User')
schema_editor.add_index(model, models.Index(fields=['email']))
migrations.RunPython(create_index)
SM025: Foreign Key Without Index¶
Severity: WARNING
Databases: All (primarily affects MySQL)
What it detects¶
Adding a ForeignKey without db_index=True (or when db_index=False):
# WARNING
migrations.AddField(
model_name='order',
name='customer',
field=models.ForeignKey(
to='myapp.Customer',
on_delete=models.CASCADE,
db_index=False, # No index!
),
)
Why it's problematic¶
Foreign keys without indexes cause:
- Slow JOIN operations
- Slow CASCADE deletes (must scan for related rows)
- Slow reverse relation queries (
customer.order_set.all()) - Lock contention on parent table deletes
Note: Django creates indexes for ForeignKey by default (db_index=True). This
rule catches when you explicitly disable it.
Safe pattern¶
# SAFE: Keep the default index (or explicitly enable)
migrations.AddField(
model_name='order',
name='customer',
field=models.ForeignKey(
to='myapp.Customer',
on_delete=models.CASCADE,
# db_index=True is the default
),
)
SM026: RunPython Without Batching¶
Severity: WARNING
Databases: All
What it detects¶
RunPython operations that use .all() without batching:
# WARNING
def migrate_data(apps, schema_editor):
User = apps.get_model('myapp', 'User')
for user in User.objects.all(): # Loads ALL users into memory!
user.name = user.name.title()
user.save()
migrations.RunPython(migrate_data)
Why it's dangerous¶
Loading all rows into memory:
- Can exhaust server memory on large tables
- Creates long-running transactions
- Holds locks for extended periods
- May timeout or be killed by the database
Safe pattern¶
# SAFE: Use batching with iterator() or chunked updates
def migrate_data(apps, schema_editor):
User = apps.get_model('myapp', 'User')
# Option 1: Use iterator with chunk_size
for user in User.objects.iterator(chunk_size=1000):
user.name = user.name.title()
user.save()
# Option 2: Bulk update in batches
batch_size = 1000
while True:
batch = list(User.objects.filter(
migrated=False
)[:batch_size])
if not batch:
break
for user in batch:
user.name = user.name.title()
user.migrated = True
User.objects.bulk_update(batch, ['name', 'migrated'])
migrations.RunPython(migrate_data)
SM027: Missing Merge Migration¶
Severity: ERROR
Databases: All
What it detects¶
Multiple leaf migrations (migrations with no children) in the same app, indicating a need for a merge migration:
App 'myapp' has multiple leaf migrations:
- 0005_add_field_a (from branch A)
- 0005_add_field_b (from branch B)
Why it's dangerous¶
Multiple leaf migrations cause:
- Ambiguous migration state
- Potential conflicts when both branches are applied
makemigrationsconfusion- CI/CD pipeline failures
This typically happens when two developers create migrations on separate branches.
Safe pattern¶
This creates a merge migration that depends on both leaves:
# 0006_merge_20240115_1234.py
class Migration(migrations.Migration):
dependencies = [
('myapp', '0005_add_field_a'),
('myapp', '0005_add_field_b'),
]
operations = []
SM028: prefer_bigint_over_int¶
| Property | Value |
|---|---|
| Rule ID | SM028 |
| Severity | WARNING |
| Category | schema-changes, performance |
| Databases | All |
What it detects¶
AddField or CreateModel using AutoField, SmallAutoField, IntegerField, or SmallIntegerField as a primary key. These 32-bit integer types max out at ~2.1 billion rows.
Why it's dangerous¶
Once a 32-bit primary key overflows, inserts fail with an "integer out of range" error (DataError). Migrating a large table from AutoField to BigAutoField requires a full table rewrite with an ACCESS EXCLUSIVE lock.
Safe pattern¶
# Use BigAutoField for new models
class MyModel(models.Model):
class Meta:
# Django 3.2+
default_auto_field = 'django.db.models.BigAutoField'
# Or set globally in settings.py
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
SM029: drop_not_null¶
| Property | Value |
|---|---|
| Rule ID | SM029 |
| Severity | WARNING |
| Category | data-loss, schema-changes |
| Databases | All |
What it detects¶
AlterField that changes a field from null=False to null=True.
Why it's dangerous¶
Dropping a NOT NULL constraint allows NULL values where they were previously prohibited. This can cause NoneType errors in application code that assumes the field is always populated.
Safe pattern¶
# Ensure application code handles NULL before making field nullable
# 1. Update code to handle None values
# 2. Then make the migration
migrations.AlterField(
model_name='user',
name='email',
field=models.CharField(max_length=255, null=True, blank=True),
)
SM030: require_concurrent_index_delete¶
| Property | Value |
|---|---|
| Rule ID | SM030 |
| Severity | ERROR |
| Category | postgresql, indexes, locking |
| Databases | PostgreSQL |
What it detects¶
RemoveIndex operations on PostgreSQL that don't use RemoveIndexConcurrently.
Why it's dangerous¶
DROP INDEX takes an ACCESS EXCLUSIVE lock, blocking all reads and writes until the operation completes. On large tables, this can cause significant downtime.
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='user_email_idx',
),
]
SM031: prefer_text_over_varchar¶
| Property | Value |
|---|---|
| Rule ID | SM031 |
| Severity | INFO |
| Category | postgresql, informational |
| Databases | PostgreSQL |
What it detects¶
AddField with CharField on PostgreSQL.
Why it's relevant¶
On PostgreSQL, VARCHAR(n) and TEXT have identical storage and performance characteristics. Using TextField avoids artificial length limits and prevents the need for future AlterField migrations to increase max_length.
Safe pattern¶
# Consider using TextField instead of CharField on PostgreSQL
migrations.AddField(
model_name='post',
name='content',
field=models.TextField(default=''),
)
SM032: prefer_timestamptz¶
| Property | Value |
|---|---|
| Rule ID | SM032 |
| Severity | INFO |
| Category | informational |
| Databases | All |
What it detects¶
AddField with DateTimeField when settings.USE_TZ is False.
Why it's relevant¶
Without timezone awareness, datetime values are stored as naive timestamps. This causes issues with daylight saving time, multi-timezone deployments, and data portability. PostgreSQL's TIMESTAMPTZ type stores UTC and converts automatically.
Safe pattern¶
SM033: adding_field_with_default¶
| Property | Value |
|---|---|
| Rule ID | SM033 |
| Severity | WARNING |
| Category | schema-changes, performance |
| Databases | All |
What it detects¶
AddField with null=False and a Python-level default value (but not db_default).
Why it's dangerous¶
When adding a NOT NULL column with a Python default, Django rewrites every existing row to set the default value. On large tables this is slow and holds locks. On PostgreSQL 11+, using db_default with a constant value avoids the table rewrite entirely.
Safe pattern¶
# Django 5.0+: Use db_default for constant values
migrations.AddField(
model_name='order',
name='status',
field=models.CharField(max_length=20, db_default='pending'),
)
# Or add as nullable first, then backfill
SM034: prefer_identity¶
| Property | Value |
|---|---|
| Rule ID | SM034 |
| Severity | INFO |
| Category | postgresql, informational |
| Databases | PostgreSQL |
What it detects¶
AutoField or BigAutoField on PostgreSQL with Django < 4.0.
Why it's relevant¶
PostgreSQL 10+ supports IDENTITY columns, which are the SQL standard replacement for SERIAL sequences. Django 4.0+ uses IDENTITY columns by default. On older Django versions, you may want to use raw SQL to create IDENTITY columns instead.
Safe pattern¶
Upgrade to Django 4.0+ where BigAutoField automatically uses IDENTITY columns on PostgreSQL.
SM035: require_lock_timeout¶
| Property | Value |
|---|---|
| Rule ID | SM035 |
| Severity | INFO |
| Category | informational |
| Databases | All |
What it detects¶
RunSQL operations containing DDL statements (ALTER TABLE, CREATE INDEX, DROP INDEX, etc.) with no SET lock_timeout statement running before the DDL — either earlier in the same SQL list or in an earlier operation. A SET lock_timeout that runs after the DDL gives it no protection and does not silence the rule.
Why it's relevant¶
DDL statements acquire locks that may block indefinitely if another transaction holds a conflicting lock. Setting lock_timeout ensures the migration fails fast instead of blocking the entire application.
Safe pattern¶
migrations.RunSQL(
sql=[
"SET lock_timeout = '5s';",
"ALTER TABLE myapp_order ADD COLUMN status VARCHAR(20);",
],
reverse_sql="ALTER TABLE myapp_order DROP COLUMN status;",
)
SM036: prefer_if_exists¶
| Property | Value |
|---|---|
| Rule ID | SM036 |
| Severity | INFO |
| Category | informational |
| Databases | All |
What it detects¶
RunSQL with CREATE TABLE without IF NOT EXISTS, or DROP TABLE without IF EXISTS.
Why it's relevant¶
Without IF [NOT] EXISTS, re-running a migration (e.g., after a partial failure with atomic=False) will fail. Defensive DDL makes migrations idempotent and safer to retry.
Safe pattern¶
migrations.RunSQL(
sql="CREATE TABLE IF NOT EXISTS myapp_cache (key TEXT PRIMARY KEY, value TEXT);",
reverse_sql="DROP TABLE IF EXISTS myapp_cache;",
)
SM047: constraint_missing_not_valid¶
| Field | Value |
|---|---|
| Rule ID | SM047 |
| Severity | WARNING |
| Databases | PostgreSQL |
What it detects¶
A RunSQL statement that runs ALTER TABLE ... ADD CONSTRAINT ... CHECK or
... FOREIGN KEY without NOT VALID.
Why it matters¶
Adding a CHECK or FOREIGN KEY constraint validates every existing row under an ACCESS EXCLUSIVE lock, blocking the table for the duration of the scan.
Safe pattern¶
migrations.RunSQL(
sql=[
"ALTER TABLE orders ADD CONSTRAINT orders_total_check "
"CHECK (total >= 0) NOT VALID;",
"ALTER TABLE orders VALIDATE CONSTRAINT orders_total_check;",
],
reverse_sql="ALTER TABLE orders DROP CONSTRAINT orders_total_check;",
)
NOT VALID adds the constraint instantly (new rows are checked); the later
VALIDATE CONSTRAINT scans existing rows under a weaker SHARE UPDATE EXCLUSIVE
lock.
SM048: truncate_in_runsql¶
| Field | Value |
|---|---|
| Rule ID | SM048 |
| Severity | WARNING |
| Databases | All |
What it detects¶
A RunSQL statement that starts with TRUNCATE.
Why it matters¶
TRUNCATE deletes all rows in a table and is not transaction-safe to undo;
TRUNCATE ... CASCADE also deletes rows from every referencing table. This is
almost never intended inside a migration and is unrecoverable.
Safe pattern¶
If you must remove data in a migration, delete rows explicitly and reversibly with a data migration, scoped to exactly the rows you mean to remove.
SM049: transaction_nesting_in_runsql¶
| Field | Value |
|---|---|
| Rule ID | SM049 |
| Severity | ERROR |
| Databases | All |
What it detects¶
Explicit BEGIN, START TRANSACTION, COMMIT, or ROLLBACK in a RunSQL
statement when the migration is atomic (the default).
Why it matters¶
Django already wraps an atomic migration in a transaction. Issuing your own transaction control creates a nested transaction, which PostgreSQL does not truly support — the statements error or leave the surrounding transaction in an unexpected state.
Safe pattern¶
Set atomic = False on the migration class if you need manual transaction
control, or remove the explicit transaction statements and let Django manage
the transaction.
SM050: drop_database_in_runsql¶
| Field | Value |
|---|---|
| Rule ID | SM050 |
| Severity | ERROR |
| Databases | All |
What it detects¶
A RunSQL statement that starts with DROP DATABASE or DROP SCHEMA.
Why it matters¶
Dropping a database or schema from a migration destroys the database/schema and all of its objects. It is catastrophic and irreversible and must never run as part of a migration.
SM040: volatile_default_with_unique¶
| Field | Value |
|---|---|
| Rule ID | SM040 |
| Severity | ERROR |
| Databases | All |
What it detects¶
AddField with unique=True and a callable default (e.g.
UUIDField(default=uuid.uuid4, unique=True)).
Why it matters¶
The migration evaluates the callable once and writes the same value to every existing row, which immediately violates the UNIQUE constraint — the migration fails on any populated table.
Safe pattern¶
# 1. Add nullable
migrations.AddField("user", "uuid", models.UUIDField(null=True))
# 2. Backfill a unique value per row (data migration)
# 3. Add the unique constraint
migrations.AlterField("user", "uuid", models.UUIDField(unique=True))
SM041: adding_stored_generated_field¶
| Field | Value |
|---|---|
| Rule ID | SM041 |
| Severity | WARNING |
| Databases | All |
| Django | 5.0+ |
What it detects¶
AddField of a GeneratedField with db_persist=True (a STORED generated
column).
Why it matters¶
A stored generated column is computed for every existing row when added,
requiring a full table rewrite under an ACCESS EXCLUSIVE lock on PostgreSQL and
MySQL. Virtual generated columns (db_persist=False) are metadata-only but are
only supported on newer database versions.
SM056: adding_exclusion_constraint¶
| Field | Value |
|---|---|
| Rule ID | SM056 |
| Severity | WARNING |
| Databases | PostgreSQL |
What it detects¶
AddConstraint with a PostgreSQL ExclusionConstraint.
Why it matters¶
Unlike CHECK and FOREIGN KEY constraints, an exclusion constraint cannot be
added NOT VALID. Adding one to a populated table always requires a full table
scan under an ACCESS EXCLUSIVE lock — there is no safe incremental approach.
Add it when the table is created, or plan for the lock.
SM037: direct_model_import_in_runpython¶
| Field | Value |
|---|---|
| Rule ID | SM037 |
| Severity | INFO |
| Databases | All |
What it detects¶
A RunPython function whose source imports a model directly
(from app.models import MyModel) instead of using
apps.get_model('app', 'MyModel'), and does not call apps.get_model.
Why it matters¶
A direct import uses the current model class rather than the historical version at migration time. It works initially but breaks when the migration is re-run against a fresh database that applies all migrations in order.
Safe pattern¶
def populate(apps, schema_editor):
MyModel = apps.get_model("app", "MyModel")
MyModel.objects.filter(...).update(...)
SM038: mixed_schema_and_data_operations¶
| Field | Value |
|---|---|
| Rule ID | SM038 |
| Severity | WARNING |
| Databases | All |
What it detects¶
A single migration that contains both schema operations (AddField, AlterField, …) and data operations (RunPython, or RunSQL containing INSERT/UPDATE/DELETE).
Why it matters¶
The data step runs inside the same migration (and often the same transaction)
as the schema change, extending how long the schema lock is held; on PostgreSQL
it can raise cannot ALTER TABLE because it has pending trigger events.
Safe pattern¶
Put the schema change in one migration and the data backfill in a separate migration that runs afterward.
SM054: multiple_heavy_ops_same_table¶
| Field | Value |
|---|---|
| Rule ID | SM054 |
| Severity | INFO |
| Databases | All |
What it detects¶
Three or more heavy schema operations (AddField, RemoveField, AlterField, AddIndex, RemoveIndex, AddConstraint, RemoveConstraint) targeting the same table in one migration.
Why it matters¶
The table lock is held for the combined duration of all the operations. Splitting them into separate migrations shortens each lock window and reduces deadlock risk.
SM042: alter_composite_primary_key¶
| Field | Value |
|---|---|
| Rule ID | SM042 |
| Severity | ERROR |
| Databases | All |
| Django | 5.2+ |
What it detects¶
An AddField or AlterField whose field is a CompositePrimaryKey on an
existing model.
Why it matters¶
Django 5.2 supports CompositePrimaryKey, but it does not support migrating
a table to (or from) a composite primary key after the table is created.
makemigrations will generate the operation, but migrate will fail.
Safe pattern¶
Define the composite primary key when the model is first created. To change an
existing table, recreate it (e.g. via SeparateDatabaseAndState plus raw SQL)
during a planned migration.
Reverse-safety rules (RV0xx)¶
The RV0xx rules are not part of the normal forward analysis. They run only
when you pass --check-reverse, and they describe what happens to the database
when a migration is rolled back. A migration can be perfectly reversible yet
have a destructive rollback path: rolling back an additive migration runs the
destructive inverse operation.
These are distinct from SM007 / SM016, which detect RunSQL / RunPython
that cannot be reversed at all. Operations whose reverse would need to
reconstruct lost state (RemoveField, DeleteModel, AlterField) are out of
scope to avoid guessing at the historical schema.
| Rule | Forward op | Rollback runs | Severity | Why it matters |
|---|---|---|---|---|
| RV001 | AddField |
DROP COLUMN |
WARNING | Rollback drops the column and any data written to it. |
| RV002 | CreateModel |
DROP TABLE |
WARNING | Rollback drops the whole table and all of its rows. |
| RV003 | AddIndex |
DROP INDEX |
INFO | DROP INDEX takes a brief exclusive lock on rollback. |
| RV004 | AddConstraint |
DROP CONSTRAINT |
INFO | Rollback removes the integrity guarantee it enforced. |
Safe pattern¶
Treat rollbacks of additive migrations as destructive by design. If a clean
rollback matters, separate additive and removal steps into distinct migrations,
or reverse a concurrently-created index with DROP INDEX CONCURRENTLY via a
hand-written RunSQL reverse.