Detect unsafe Django migrations before they break production
Project description
Django Safe Migrations
Detect unsafe Django migrations before they break production.
Django Safe Migrations analyzes your Django migrations and warns you about operations that could cause downtime, lock tables, or cause data loss in production environments.
Features
- Detect unsafe operations before they reach production (see what we catch)
- PostgreSQL-aware rules for concurrent index creation and more
- Clear fix suggestions with safe migration patterns
- Multiple output formats: Console (with colors), JSON, GitHub Actions annotations, SARIF
- Easy CI/CD integration with GitHub Actions, pre-commit hooks, and GitHub Code Scanning
- Configurable rules to match your deployment strategy
Rules
| Rule ID | Name | Severity | Description |
|---|---|---|---|
| SM001 | not_null_without_default |
ERROR | Adding NOT NULL column without default will lock table |
| SM002 | drop_column_unsafe |
WARNING | Dropping column while old code may reference it |
| SM003 | drop_table_unsafe |
WARNING | Dropping table while old code may reference it |
| SM004 | alter_column_type |
WARNING | Changing column type may rewrite table |
| SM005 | add_foreign_key_validates |
WARNING | FK constraint validates existing rows (locks) |
| SM006 | rename_column |
INFO | Column rename may break old code during deployment |
| SM007 | run_sql_unsafe |
WARNING | RunSQL without reverse_sql is not reversible |
| SM008 | large_data_migration |
INFO | Data migration may be slow on large tables |
| SM009 | add_unique_constraint |
ERROR | Adding unique constraint requires full table scan |
| SM010 | index_not_concurrent |
ERROR | Index creation without CONCURRENTLY (PostgreSQL) |
| SM011 | unique_constraint_not_concurrent |
ERROR | Unique constraint without concurrent index |
| SM012 | enum_add_value_transaction |
ERROR | Adding enum value inside transaction (PostgreSQL) |
| SM013 | alter_varchar_length |
WARNING | Decreasing VARCHAR length rewrites table |
| SM014 | rename_model |
WARNING | Model rename may break FKs and external references |
| SM015 | alter_unique_together |
WARNING | Deprecated in favor of UniqueConstraint |
| SM016 | run_python_no_reverse |
INFO | RunPython without reverse_code is not reversible |
| SM017 | add_check_constraint |
WARNING | Check constraint validates all existing rows |
| SM018 | concurrent_in_atomic_migration |
ERROR | Concurrent index operations require atomic=False |
| SM019 | reserved_keyword_column |
INFO | Column name is a reserved SQL keyword |
| SM020 | alter_field_null_false |
ERROR | AlterField null=False without data backfill |
| SM021 | alter_field_unique |
ERROR | Adding UNIQUE via AlterField on existing data |
| SM022 | expensive_default_callable |
WARNING | Default value uses expensive callable (e.g., now()) |
| SM023 | add_many_to_many |
INFO | ManyToMany creates junction table (potential locks) |
| SM024 | sql_injection_pattern |
ERROR | SQL injection patterns detected in RunSQL |
| SM025 | fk_without_index |
WARNING | Foreign key without db_index on large tables |
| SM026 | run_python_no_batching |
WARNING | RunPython using .all() without batching |
| SM027 | missing_merge_migration |
ERROR | Multiple leaf migrations need merge migration |
Installation
pip install django-safe-migrations
Add to your INSTALLED_APPS:
INSTALLED_APPS = [
...
'django_safe_migrations',
]
Usage
Management Command
# Check all migrations
python manage.py check_migrations
# Check specific apps
python manage.py check_migrations myapp otherapp
# Only check unapplied migrations
python manage.py check_migrations --new-only
# JSON output for CI
python manage.py check_migrations --format=json
# GitHub Actions annotations
python manage.py check_migrations --format=github
# Fail on warnings too
python manage.py check_migrations --fail-on-warning
Example Output
Found 2 migration issue(s):
โ ERROR [SM001] myapp/migrations/0002_add_email.py:15
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),
)
โ WARNING [SM002] myapp/migrations/0003_remove_old.py:10
Dropping column 'old_field' from 'user' - ensure all code references have been removed first
Operation: RemoveField(user.old_field)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary: 1 error(s), 1 warning(s)
๐ CI/CD Integration
GitHub Actions
# .github/workflows/check-migrations.yml
name: Check Migrations
on:
pull_request:
paths:
- "**/migrations/**"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install django-safe-migrations Django
- name: Check migrations
run: python manage.py check_migrations --format=github --fail-on-warning
Pre-commit Hook
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: check-migrations
name: Check Django migrations
entry: python manage.py check_migrations --new-only
language: system
types: [python]
pass_filenames: false
โ๏ธ Configuration
Command Options
| Option | Description |
|---|---|
--format |
Output format: console, json, github, sarif |
--output, -o |
Output file path (defaults to stdout) |
--fail-on-warning |
Exit with error code on warnings |
--new-only |
Only check unapplied migrations |
--no-suggestions |
Hide fix suggestions |
--exclude-apps |
Apps to exclude from checking |
--include-django-apps |
Include Django's built-in apps |
Programmatic Usage
from django_safe_migrations import MigrationAnalyzer
analyzer = MigrationAnalyzer()
# Analyze all migrations
issues = analyzer.analyze_all()
# Analyze specific app
issues = analyzer.analyze_app('myapp')
# Analyze only new migrations
issues = analyzer.analyze_new_migrations()
for issue in issues:
print(f"[{issue.rule_id}] {issue.message}")
if issue.suggestion:
print(f"Suggestion: {issue.suggestion}")
๐ Safe Migration Patterns
Adding a NOT NULL Column
โ Unsafe:
migrations.AddField(
model_name='user',
name='email',
field=models.CharField(max_length=255), # NOT NULL, no default!
)
โ Safe:
# Migration 1: Add nullable field
migrations.AddField(
model_name='user',
name='email',
field=models.CharField(max_length=255, null=True),
)
# Migration 2: Backfill data (data migration)
def backfill_emails(apps, schema_editor):
User = apps.get_model('myapp', 'User')
User.objects.filter(email__isnull=True).update(email='default@example.com')
migrations.RunPython(backfill_emails)
# Migration 3: Add NOT NULL constraint
migrations.AlterField(
model_name='user',
name='email',
field=models.CharField(max_length=255),
)
Creating an Index (PostgreSQL)
โ Unsafe:
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['email'], name='user_email_idx'),
)
โ Safe:
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'),
),
]
โ ๏ธ Known Limitations
Static Analysis Only
Django Safe Migrations performs static analysis of migration files. It cannot:
- Know the actual size of your tables (all tables are treated equally)
- Detect issues that depend on runtime data (e.g., whether NULL values exist)
- Know your specific deployment strategy or downtime tolerance
Recommendation: Use suppression comments with explanations when you've verified a migration is safe for your specific situation.
Database-Specific Rules
Some rules only apply to PostgreSQL:
| Rule | PostgreSQL Only | Reason |
|---|---|---|
| SM010 | Yes | CONCURRENTLY is PostgreSQL-specific |
| SM011 | Yes | Concurrent unique indexes are PostgreSQL-specific |
| SM012 | Yes | Enum handling is PostgreSQL-specific |
| SM018 | Yes | AddIndexConcurrently is PostgreSQL-specific |
| SM021 | Yes | Concurrent unique constraint pattern |
For MySQL, SQLite, or other databases, these rules are automatically skipped.
Cannot Detect All Unsafe Patterns
The analyzer may miss unsafe patterns in:
- Complex
RunSQLstatements (only basic pattern matching) - Dynamic SQL generated at runtime
- Migrations that call external services
- Custom migration operations
False Positives
Some detected issues may be false positives:
- SM001 on new tables (no existing rows to worry about)
- SM010 on small lookup tables (concurrent not needed)
- SM020 when you've already backfilled NULL values
Use suppression comments to document why a pattern is safe in your case:
# safe-migrations: ignore SM001 -- new table, no existing data
migrations.AddField(...)
Source Inspection Limitations
Rules that inspect Python source code (like SM026 for RunPython batching) may not work in all environments:
- Compiled/optimized Python installations
- Some Docker configurations
- When source files are not available
๐ค Contributing
Contributions are welcome! Please read our Contributing Guide for details.
# Clone the repo
git clone https://github.com/YasserShkeir/django-safe-migrations.git
cd django-safe-migrations
# Install dev dependencies
pip install -e ".[dev]"
pre-commit install
# Run tests
pytest
# Run linters
make lint
๐ License
MIT License - see LICENSE for details.
๐ Support
If this project helps you ship safer migrations, consider supporting its development:
๐ Acknowledgments
Inspired by:
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_safe_migrations-0.5.0.tar.gz.
File metadata
- Download URL: django_safe_migrations-0.5.0.tar.gz
- Upload date:
- Size: 179.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6cc9070c2f5ee0640ee12d6a92741b533de2719521df82420ff5f88156da75af
|
|
| MD5 |
f6387e5928c39f45d804643ebafeca74
|
|
| BLAKE2b-256 |
14af8027bb294cfd866fa7fd75f9f21a52b0c6b15a2c71ccc38c04aef60a02cd
|
Provenance
The following attestation bundles were made for django_safe_migrations-0.5.0.tar.gz:
Publisher:
publish.yml on YasserShkeir/django-safe-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_safe_migrations-0.5.0.tar.gz -
Subject digest:
6cc9070c2f5ee0640ee12d6a92741b533de2719521df82420ff5f88156da75af - Sigstore transparency entry: 927176051
- Sigstore integration time:
-
Permalink:
YasserShkeir/django-safe-migrations@12533ff9eb61ab90b9d30a463ca3e311ace4239b -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/YasserShkeir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@12533ff9eb61ab90b9d30a463ca3e311ace4239b -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_safe_migrations-0.5.0-py3-none-any.whl.
File metadata
- Download URL: django_safe_migrations-0.5.0-py3-none-any.whl
- Upload date:
- Size: 81.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b58930e9e2f8671a6d9eb3feda1bfba8c59f7a5d01d9f6238454c48bc1ab65d3
|
|
| MD5 |
4033445eb1bf7eef15e11e122478ffaf
|
|
| BLAKE2b-256 |
f31b68ec580121b06f4113aee5e1cf6da88726b9a4a3b2fa788db378c0d6c44a
|
Provenance
The following attestation bundles were made for django_safe_migrations-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on YasserShkeir/django-safe-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_safe_migrations-0.5.0-py3-none-any.whl -
Subject digest:
b58930e9e2f8671a6d9eb3feda1bfba8c59f7a5d01d9f6238454c48bc1ab65d3 - Sigstore transparency entry: 927176053
- Sigstore integration time:
-
Permalink:
YasserShkeir/django-safe-migrations@12533ff9eb61ab90b9d30a463ca3e311ace4239b -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/YasserShkeir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@12533ff9eb61ab90b9d30a463ca3e311ace4239b -
Trigger Event:
push
-
Statement type: