Drift Detection
DriftDetector compares what django_migrations says ran against what actually exists in the PostgreSQL schema. It is entirely read-only — it never mutates the history table or the live schema.
Drift classification
Every drift incident is classified into one of three directions:
| Direction | What happened | Symptom on next migrate | Repair strategy |
|---|---|---|---|
recorded_missing | django_migrations has the row but the DDL effect (table or column) is not in the live DB. | ProgrammingError: column does not exist when the ORM uses the field. | Fake-rewind to before the drifted migration → plain migrate re-applies it forward. |
unrecorded_present | The DDL effects exist in the DB but django_migrations has no record of the migration running. | ProgrammingError: relation already exists when migrate tries to create the table. | Fake-apply the missing record (zero DDL effect — the schema is already correct). |
half_applied | An unrecorded migration where CreateModel ran but follow-up AddField operations did not. | Same as unrecorded_present for the table itself — migrate would fail on the existing table. | Fake-apply to unblock further migrations, then add a follow-up migration or restore from backup to recover the missing columns. |
Foreign tables (tables present on this DB whose owning app routes to a different database) are listed separately and never auto-touched.
What is introspected
DriftDetector only checks operations it can introspect cheaply from the live schema:
CreateModel— checks whether the table exists usinginformation_schema.tables.AddField— checks whether the column exists usinginformation_schema.columns. Resolves actual column names correctly: ForeignKey fields get an_idsuffix, and explicitdb_columnoverrides are respected.
All other operations (RunPython, RunSQL, RenameField, RemoveField, etc.) are trusted based on the django_migrations record. If a migration contains only non-introspectable operations, it is skipped by the drift scanner — no false positives.
Tri-state result
The core check returns one of three values:
| Result | Meaning |
|---|---|
True | All introspectable operations have their effects present in the DB. |
False | At least one introspectable operation’s effect is missing from the DB. |
None | No introspectable operations in this migration — skip it entirely. |
Half-applied detection
Half-applied incidents are a special case: an unrecorded migration where the CreateModel table exists but subsequent AddField columns do not. This happens when:
- A
CREATE TABLEstatement ran (manually or from a partial migration run) but theALTER TABLE ADD COLUMNstatements that follow in the same migration file did not. - The migration was never recorded in
django_migrations.
Plain migrate would fail on this state because it would try to CREATE TABLE on an already-existing table. The repair strategy is to fake-apply the record so migrate sees the migration as done, then surface a warning that the missing columns need manual recovery.
Running a drift scan programmatically
from django_cfg.modules.django_migrator import Migrator, MigratorOptions
# Forensic check — no side effects
migrator = Migrator(options=MigratorOptions(dry_run=True))
report = migrator.check()
for db in report.db_reports:
if db.drift and db.drift.has_issues():
drift = db.drift
print(f"{db.alias}: {drift.total_drift_count()} drift incidents")
for inc in drift.recorded_missing:
print(f" RECORDED_MISSING {inc.app_label}.{inc.migration_name}")
print(f" detail: {inc.detail}")
for inc in drift.unrecorded_present:
print(f" UNRECORDED_PRESENT {inc.app_label}.{inc.migration_name}")
print(f" detail: {inc.detail}")
for inc in drift.half_applied:
print(f" HALF_APPLIED {inc.app_label}.{inc.migration_name}")
print(f" detail: {inc.detail}")
for table in drift.foreign_tables:
print(f" FOREIGN TABLE: {table} (app routes elsewhere)")DriftReport data structure
@dataclass
class DriftReport:
alias: str
recorded_missing: list[DriftIncident]
unrecorded_present: list[DriftIncident]
half_applied: list[DriftIncident]
foreign_tables: list[str] # never auto-dropped
def has_issues(self) -> bool: ...
def total_drift_count(self) -> int: ...
@dataclass
class DriftIncident:
app_label: str
migration_name: str
direction: Literal["recorded_missing", "unrecorded_present", "half_applied"]
detail: str # human-readable description of the specific mismatchCommon causes of drift
| Scenario | Resulting drift type |
|---|---|
Ran migrate --fake manually to skip a migration, then a deploy applied the DDL directly. | unrecorded_present |
Partial restore from backup that didn’t include django_migrations. | unrecorded_present |
Migration ran in django_migrations but the DB was restored to before the DDL ran. | recorded_missing |
Emergency hotfix applied a CREATE TABLE directly in SQL, migration file added later. | half_applied or unrecorded_present |
| Interrupted migration run that committed DDL but not the history row. | unrecorded_present |
| Parallel migration agents that caused a race on the history table. | recorded_missing or unrecorded_present |