Skip to Content
FeaturesModulesMigratordjango_migrator — Multi-Database Migration Orchestrator

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:

  1. migrate <app> --database=X bypasses the router — per-app loops in helper scripts route incorrectly the moment routing rules change. Plain migrate --database=X (no app filter) respects the router, but requires every app’s allow_migrate to be correct.

  2. django_migrations drifts from actual DDL. Manual hotfixes, partial restores, interrupted runs, and parallel agents all create silent drift that surfaces hours later as column does not exist or relation already exists.

  3. TEST.MIRROR leaks into production. The setting lives in DATABASES permanently, not just during tests. A naive router collapses the routed alias onto its source, silently routing writes to the wrong database.

  4. No drift detection. Django trusts django_migrations and 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 alias

Management 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-makemigrations

Exit 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 catalog

The 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 / RenameField not 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_schema and PostgreSQL advisory locks.
Last updated on