django_migrator — Multi-Database Migration Orchestrator
django_migrator replaces bare migrate calls with a layered orchestrator that introspects the physical database before issuing any DDL. Deploys fail loudly on drift rather than half-succeeding and surfacing as a ProgrammingError deep inside a request handler hours later.
Why this exists
Django’s built-in migrate command works fine for single-database projects. In multi-database setups you hit operational gaps the docs do not cover:
-
migrate <app> --database=Xbypasses the router — per-app loops in helper scripts route incorrectly the moment routing rules change. Plainmigrate --database=X(no app filter) respects the router, but requires every app’sallow_migrateto be correct. -
django_migrationsdrifts from actual DDL. Manual hotfixes, partial restores, interrupted runs, and parallel agents all create silent drift that surfaces hours later ascolumn does not existorrelation already exists. -
TEST.MIRRORleaks into production. The setting lives inDATABASESpermanently, not just during tests. A naive router collapses the routed alias onto its source, silently routing writes to the wrong database. -
No drift detection. Django trusts
django_migrationsand never compares it against the live schema.
This module addresses all of the above with one consistent lifecycle per database.
Per-database lifecycle
For each alias in DATABASES (default first, then alphabetical):
1. Guards
├─ connection_live — SELECT 1, fail fast on dead DB
├─ test_mirror_isolation — refuse if MIRROR is actively routing
│ writes outside of test mode
└─ no_concurrent_migration — detect another migrate holding the
PostgreSQL advisory lock
2. DriftDetector.scan(alias)
→ returns DriftReport with three classified incident lists
3. If drift detected:
├─ --repair: RepairEngine.apply()
│ ├─ fake-apply unrecorded_present (history catches up)
│ ├─ fake-apply half_applied + warning for manual recovery
│ └─ fake-rewind recorded_missing (so migrate re-applies)
└─ otherwise: add error to DbReport, exit 1
4. Companion fake-apply (django_currency etc.)
5. call_command("migrate", database=alias)
— no app filter, router's allow_migrate decides
6. Verify: no migrations pending for apps owned by this aliasManagement commands
migrate_all
Run the full lifecycle across every database in DATABASES.
# Default: detect drift, abort if found, otherwise migrate
python manage.py migrate_all
# Forensic check only — no DDL, no record changes
python manage.py migrate_all --check
# Auto-repair drift, then migrate
python manage.py migrate_all --repair
# CI mode — no interactive prompts
python manage.py migrate_all --repair --non-interactive
# Skip makemigrations step (faster when migrations are up to date)
python manage.py migrate_all --skip-makemigrationsExit code is 0 when all databases are clean, 1 otherwise. Drift detected without --repair produces a non-zero exit so CI fails fast.
migrator
Interactive menu for day-to-day developer flow.
# Open the interactive menu
python manage.py migrator
# Skip menu, run full migration without prompts
python manage.py migrator --auto
python manage.py migrator --auto --repair
# Migrate one database alias only
python manage.py migrator --database vehicles
# Migrate one app across every DB that owns it
python manage.py migrator --app catalogThe menu offers: full migrate, full migrate with --repair, forensic check, makemigrations only, per-DB migrate, and status view.
Public API
from django_cfg.modules.django_migrator import (
Migrator,
MigratorOptions,
MigratorLogger,
TextReportFormatter,
register_fake_detector,
)
migrator = Migrator(
options=MigratorOptions(
repair=True, # auto-fix drift before migrating
dry_run=False, # True = forensic check only, no DDL
interactive=False, # CI mode — no prompts
skip_makemigrations=False,
verbosity=1,
),
log=MigratorLogger(),
)
report = migrator.migrate_all()
print(TextReportFormatter().render(report))
if not report.all_clean:
raise SystemExit(1)For forensic-only checks:
report = migrator.check() # never mutates anything
for db in report.db_reports:
count = db.drift.total_drift_count() if db.drift else 0
print(f"{db.alias}: drift={count}")Known caveats
- No FDW orchestration. Cross-database joins are still developer-handled.
RemoveField/RenameFieldnot detected as drift. These surface at query time when a column has the wrong name, not at migrate time.- Half-applied migrations require manual recovery. Auto-repair fake-applies them to unblock further migrations, but recreating missing columns is a manual step (add a follow-up migration or restore from backup).
- PostgreSQL only. The schema inspector uses
information_schemaand PostgreSQL advisory locks.