Configuration¶
Django Safe Migrations can be configured through Django settings, a
pyproject.toml section, or command-line options.
pyproject.toml¶
As an alternative to the SAFE_MIGRATIONS Django setting, you can configure the
linter in pyproject.toml — convenient for CLI / pre-commit usage. Keys are the
lower-cased versions of the setting keys; Django settings take precedence over
pyproject.toml when both are present. Requires Python 3.11+ (tomllib) or the
tomli package.
[tool.django_safe_migrations]
disabled_rules = ["SM006", "SM008"]
disabled_categories = ["informational"]
database_vendor = "postgresql"
warnings_as_errors = ["SM002", "SM003"]
Linting at generation time and blocking migrate¶
makemigrationslinting — once the app is installed,makemigrationsanalyses newly generated migrations and warns about safety issues. It is warn-only by default; pass--lint-strictto fail, or--no-lintto skip.migrateblocking — setBLOCK_UNSAFE = True(below) to register a Django system check that preventsmigratefrom running while ERROR-level migration issues exist.
Django Settings¶
Add a SAFE_MIGRATIONS dictionary to your Django settings to customize behavior:
# settings.py
SAFE_MIGRATIONS = {
# Disable specific rules by ID
"DISABLED_RULES": ["SM006", "SM008"],
# Disable entire categories of rules
"DISABLED_CATEGORIES": ["reversibility"],
# Enable only specific categories (whitelist mode)
# "ENABLED_CATEGORIES": ["destructive", "high-risk"],
# Override severity levels for specific rules
"RULE_SEVERITY": {
"SM002": "INFO", # Downgrade from WARNING to INFO
},
# Apps to exclude from checking (extends defaults)
"EXCLUDED_APPS": [
"admin",
"auth",
"contenttypes",
"sessions",
"messages",
"staticfiles",
# Add your own apps to exclude:
"django_celery_beat",
"oauth2_provider",
],
# Per-app rule configuration
"APP_RULES": {
"legacy_app": {
"DISABLED_RULES": ["SM001", "SM002"], # Relax rules for legacy
},
"critical_app": {
"ENABLED_CATEGORIES": ["high-risk"], # Only check critical rules
},
},
# Fail on warnings (same as --fail-on-warning)
"FAIL_ON_WARNING": False,
}
DISABLED_RULES¶
List of rule IDs to completely disable. Disabled rules won't be checked at all:
SAFE_MIGRATIONS = {
"DISABLED_RULES": [
"SM006", # Don't warn about column renames
"SM008", # Don't warn about data migrations
],
}
DISABLED_CATEGORIES¶
Disable entire categories of rules at once. Available categories:
| Category | Description | Rules |
|---|---|---|
postgresql |
PostgreSQL-specific rules | SM005, SM010, SM011, SM012, SM013, SM017, SM018, SM021, SM030, SM031, SM034, SM047, SM056 |
mysql |
MySQL-specific rules | (none currently) |
sqlite |
SQLite-specific rules | (none currently) |
indexes |
Index-related operations | SM010, SM011, SM018, SM021, SM030 |
constraints |
Constraint operations | SM009, SM011, SM015, SM017, SM020, SM021, SM040, SM047, SM056 |
destructive |
Destructive operations | SM002, SM003, SM009, SM048, SM050 |
relations |
Relation operations | SM005, SM023, SM025 |
locking |
Table-locking operations | SM004, SM005, SM010, SM011, SM013, SM020, SM021, SM030, SM041, SM047, SM054, SM056 |
data-loss |
Potential data loss | SM002, SM003, SM009, SM029, SM040, SM048, SM050 |
reversibility |
Non-reversible migrations | SM007, SM016, SM017, SM049 |
data-migrations |
Data migration concerns | SM007, SM008, SM016, SM017, SM022, SM026, SM037, SM038 |
security |
Security-related rules | SM024 |
high-risk |
High-risk operations | SM001, SM002, SM003, SM010, SM011, SM018, SM020, SM021, SM024, SM027, SM030, SM040, SM042, SM049, SM050 |
informational |
Info-level warnings | SM006, SM014, SM019, SM023, SM031, SM032, SM034, SM035, SM036 |
naming |
Naming convention rules | SM019 |
schema-changes |
Schema modification rules | SM001, SM002, SM003, SM004, SM006, SM013, SM014, SM020, SM021, SM023, SM027, SM028, SM029, SM033, SM038, SM041, SM042 |
performance |
Performance concerns | SM022, SM025, SM026, SM028, SM033, SM041, SM054 |
SAFE_MIGRATIONS = {
"DISABLED_CATEGORIES": [
"reversibility", # Don't check for reversible migrations
"informational", # Suppress info-level warnings
],
}
ENABLED_CATEGORIES¶
When set, enables whitelist mode — only rules in the specified categories will run:
SAFE_MIGRATIONS = {
# Only check high-risk and destructive operations
"ENABLED_CATEGORIES": ["high-risk", "destructive"],
}
Note
If both ENABLED_CATEGORIES and DISABLED_CATEGORIES are set,
ENABLED_CATEGORIES is applied first (whitelist), then
DISABLED_CATEGORIES removes rules from that set.
RULE_SEVERITY¶
Override the severity level for specific rules. Valid values are "ERROR", "WARNING", and "INFO":
SAFE_MIGRATIONS = {
"RULE_SEVERITY": {
"SM002": "INFO", # Downgrade drop column from WARNING to INFO
"SM006": "WARNING", # Upgrade rename column from INFO to WARNING
},
}
EXCLUDED_APPS¶
List of Django app labels to skip when checking migrations:
SAFE_MIGRATIONS = {
"EXCLUDED_APPS": [
"admin",
"auth",
"contenttypes",
"sessions",
"messages",
"staticfiles",
# Third-party apps you don't control:
"django_celery_beat",
"allauth",
],
}
FAIL_ON_WARNING¶
If True, warnings will cause a non-zero exit code (same as --fail-on-warning):
WARNINGS_AS_ERRORS¶
Promote specific warning-level rules to build failures (more granular than
FAIL_ON_WARNING). A warning from one of these rules causes a non-zero exit:
DATABASE_VENDOR¶
Override the auto-detected database vendor used for rule analysis — useful when
you develop on SQLite but deploy to PostgreSQL. Accepts postgresql, mysql,
or sqlite:
BLOCK_UNSAFE¶
When True, an opt-in Django system check reports ERROR-level migration issues,
so migrate (and other commands) refuse to run while unsafe migrations exist.
Disabled by default:
APP_RULES¶
Configure rules on a per-app basis. Each app can have its own DISABLED_RULES, DISABLED_CATEGORIES, ENABLED_CATEGORIES, and RULE_SEVERITY:
SAFE_MIGRATIONS = {
"APP_RULES": {
# Legacy app with relaxed rules
"legacy_app": {
"DISABLED_RULES": ["SM001", "SM002"],
"RULE_SEVERITY": {"SM004": "INFO"},
},
# Critical app with strict rules
"payments": {
"ENABLED_CATEGORIES": ["high-risk", "destructive"],
},
# Third-party integration with category disabled
"webhooks": {
"DISABLED_CATEGORIES": ["indexes"],
},
},
}
Priority order (highest to lowest):
- App-specific
DISABLED_RULES - App-specific
DISABLED_CATEGORIES/ENABLED_CATEGORIES - Global
DISABLED_RULES - Global
DISABLED_CATEGORIES/ENABLED_CATEGORIES
This means you can have strict global rules but relax them for specific apps:
SAFE_MIGRATIONS = {
# Global: enable strict mode
"ENABLED_CATEGORIES": ["high-risk"],
"APP_RULES": {
# But for legacy_app, allow everything
"legacy_app": {
"ENABLED_CATEGORIES": [], # Empty = no category filtering
},
},
}
Inline Suppression Comments¶
You can suppress specific rules on a per-operation basis using inline comments in your migration files.
Syntax¶
# safe-migrations: ignore SM001
# safe-migrations: ignore SM001, SM002
# safe-migrations: ignore SM001 -- reason for suppression
# safe-migrations: ignore all
Usage¶
Place the suppression comment on the line immediately before the operation, or on the same line:
operations = [
# safe-migrations: ignore SM001 -- adding nullable first, will add NOT NULL later
migrations.AddField(
model_name='user',
name='email',
field=models.CharField(max_length=255, null=True),
),
# safe-migrations: ignore SM002, SM003 -- intentional cleanup, field unused
migrations.RemoveField(
model_name='user',
name='legacy_field',
),
migrations.AddIndex( # safe-migrations: ignore SM010 -- small table
model_name='setting',
index=models.Index(fields=['key'], name='setting_key_idx'),
),
]
Ignore All Rules¶
To suppress all rules for an operation:
# safe-migrations: ignore all -- this migration has been reviewed
migrations.RunSQL(
sql='...',
reverse_sql='...',
)
Best Practices¶
- Always include a reason — Future developers (including yourself) will want to know why:
- Be specific — Only suppress the rules that apply:
-
Keep suppressions minimal — If you're suppressing many rules, consider if the migration is actually safe.
-
Document in PR — When adding suppressions, explain in your PR description why it's safe.
Command Options¶
--format¶
Choose the output format:
# Console output with colors (default)
python manage.py check_migrations --format=console
# JSON output for parsing
python manage.py check_migrations --format=json
# GitHub Actions annotations
python manage.py check_migrations --format=github
# GitLab Code Quality format
python manage.py check_migrations --format=gitlab
# SARIF for GitHub Code Scanning
python manage.py check_migrations --format=sarif
--fail-on-warning¶
By default, only ERROR severity issues cause a non-zero exit code. Use this to also fail on warnings:
--warnings-as-errors¶
Fail only on warnings from specific rules (comma-separated), instead of all warnings:
--database-vendor¶
Analyze as if running against a specific database, regardless of the configured one (e.g. develop on SQLite, lint for PostgreSQL):
--new-only¶
Only check migrations that haven't been applied yet:
This is useful in CI to only check new migrations in a PR.
--no-suggestions¶
Hide the fix suggestions in output:
--output / -o¶
Write the report to a file instead of stdout:
python manage.py check_migrations --format=json --output report.json
python manage.py check_migrations --format=sarif -o results.sarif
--exclude-apps¶
Exclude specific apps from checking:
--include-django-apps¶
By default, Django's built-in apps (auth, admin, etc.) are excluded. Include them with:
--diff¶
Only check migrations that have changed since a base branch:
# Diff against main (default)
python manage.py check_migrations --diff
# Diff against a specific branch
python manage.py check_migrations --diff develop
--diff compares against your working tree, so it also includes uncommitted
migration edits.
--since-commit¶
Only check migrations changed in the committed range COMMIT..HEAD. Unlike
--diff, uncommitted working-tree edits are ignored, which makes it suited to
incremental CI ("lint only what was committed since the last green build"):
# Check everything committed since the last successful pipeline SHA
python manage.py check_migrations --since-commit "$LAST_GREEN_SHA"
# Check what this branch added on top of main (committed only)
python manage.py check_migrations --since-commit origin/main
--diff and --since-commit are mutually exclusive; passing both exits with
code 2.
--cache / --cache-file¶
Cache analysis results so unchanged migrations are not re-analysed on the next run. This is useful for pre-commit hooks and CI steps that run repeatedly:
# Use the default cache file (.dsm_cache.json in the working directory)
python manage.py check_migrations --cache
# Use a custom cache location (implies --cache)
python manage.py check_migrations --cache-file .cache/dsm.json
How it stays correct:
- Each migration's cache entry is keyed on a content hash that combines the migration's own file and the files of its transitive dependencies, so a change to an ancestor migration (which can change before-state resolution) invalidates the entry.
- The whole cache is namespaced by a fingerprint of the package version, the
active rule IDs, the database vendor, the Django version,
USE_TZand the resolved configuration. Any change to these discards the entire cache, so an upgrade or config change never serves stale results.
The cache is opt-in and best-effort: an unreadable or corrupt cache file is
ignored rather than failing the run. The cache file is safe to delete at any
time and should usually be added to .gitignore.
--check-reverse¶
Also analyse each migration's rollback path. A migration can be perfectly reversible yet have a backwards path that is destructive in production — rolling back an additive migration runs the destructive inverse:
This emits issues in the RV0xx family (separate from the forward SM0xx
rules, and only when the flag is given):
| Rule | Forward op | Rollback runs | Severity |
|---|---|---|---|
| RV001 | AddField |
DROP COLUMN |
WARNING |
| RV002 | CreateModel |
DROP TABLE |
WARNING |
| RV003 | AddIndex |
DROP INDEX |
INFO |
| RV004 | AddConstraint |
DROP CONSTRAINT |
INFO |
This is distinct from SM007 / SM016, which flag RunSQL / RunPython that
cannot be reversed at all. Operations whose reverse would need to reconstruct
lost state (RemoveField, DeleteModel, AlterField) are intentionally out of
scope to avoid guessing.
--classify-phase¶
Classify each migration into a deployment phase to help order
expand–contract (blue-green / rolling) deploys, then exit. No safety analysis is
run and the exit code is always 0:
python manage.py check_migrations --classify-phase
python manage.py check_migrations --classify-phase --format=json
| Phase | Operations | Deploy… |
|---|---|---|
expand |
AddField, CreateModel, AddIndex, AddConstraint |
before the new code |
contract |
RemoveField, DeleteModel, Rename*, AlterField |
after the new code |
data |
RunPython, RunSQL |
per data plan |
mixed |
more than one of the above | split it first |
empty |
only Python-only changes (e.g. AlterModelOptions) |
anytime |
--classify-phase honours app-label arguments and --exclude-apps. The phase
of an in-place AlterField is heuristic — it is reported as contract (the
conservative "deploy after code" choice).
--baseline¶
Exclude issues that are present in a baseline file:
--generate-baseline¶
Generate a baseline file from current issues and exit:
This is useful for adopting django-safe-migrations incrementally on an existing project.
--interactive¶
Interactively review each issue with options to keep, skip, show fix, or quit:
--verbose¶
Show progress information during analysis:
--watch¶
Watch migration files for changes and re-run analysis automatically:
Requires the watchdog package: pip install django-safe-migrations[watch]
--list-rules¶
List all available rules and exit:
python manage.py check_migrations --list-rules
python manage.py check_migrations --list-rules --format=json
Exit Codes¶
| Code | Meaning |
|---|---|
| 0 | No issues found (or only INFO) |
| 1 | ERROR found, or WARNING with --fail-on-warning |
Programmatic Usage¶
from django_safe_migrations import MigrationAnalyzer
from django_safe_migrations.rules.base import Severity
analyzer = MigrationAnalyzer()
# Analyze all migrations
issues = analyzer.analyze_all()
# Filter by severity
errors = [i for i in issues if i.severity == Severity.ERROR]
warnings = [i for i in issues if i.severity == Severity.WARNING]
# Get summary
summary = analyzer.get_summary(issues)
print(f"Total: {summary['total']}")
print(f"Errors: {summary['by_severity']['error']}")
Custom Rules¶
You can provide your own rules:
from django_safe_migrations import MigrationAnalyzer
from django_safe_migrations.rules.base import BaseRule, Issue, Severity
class MyCustomRule(BaseRule):
rule_id = "CUSTOM001"
severity = Severity.WARNING
description = "My custom rule"
def check(self, operation, migration, **kwargs):
# Your logic here
return None # or return Issue(...)
# Use custom rules
analyzer = MigrationAnalyzer(rules=[MyCustomRule()])
issues = analyzer.analyze_all()
EXTRA_RULES Configuration¶
You can register custom rules via Django settings using dotted import paths:
# settings.py
SAFE_MIGRATIONS = {
"EXTRA_RULES": [
"myapp.migrations.rules.NoDropColumnRule",
"myapp.migrations.rules.RequireReviewRule",
],
}
Each path must be a fully qualified dotted path to a class that extends BaseRule.
Security Considerations¶
Important: The
EXTRA_RULESsetting uses dynamic imports viaimportlib.import_module().
Risk Assessment:
| Aspect | Status | Notes |
|---|---|---|
| Risk Level | LOW | Settings are developer-controlled |
| Attack Vector | None | No user input reaches this code |
| Mitigation | Built-in | Only trusted code in settings.py |
Best Practices:
-
Only use trusted paths - The import paths in
EXTRA_RULESwill be dynamically imported and executed. Only add paths to code you control. -
Review third-party rules - If using rules from external packages, review the source code before adding them.
-
Don't use user input - Never construct
EXTRA_RULESpaths from user-supplied data:
# NEVER DO THIS
SAFE_MIGRATIONS = {
"EXTRA_RULES": [os.environ.get("CUSTOM_RULE")], # Dangerous!
}
# SAFE - hardcoded paths only
SAFE_MIGRATIONS = {
"EXTRA_RULES": ["myapp.rules.MyRule"],
}
- Validate in CI - If you accept rule configurations in CI, validate paths against an allowlist:
ALLOWED_RULES = {
"myapp.rules.StrictMode",
"myapp.rules.RequireTests",
}
# Validate before use
for rule_path in extra_rules:
if rule_path not in ALLOWED_RULES:
raise ValueError(f"Untrusted rule: {rule_path}")
Why This is Safe:
- Django settings files (
settings.py) are Python code that runs with full privileges - Any code in settings can already execute arbitrary Python
EXTRA_RULESdoesn't introduce new attack surface - it's equivalent to a regularimportstatement- The setting is never exposed to end users or HTTP requests