skip to content

How does Django's makemigrations work out what changed in your models, and why doesn't it compare against the database schema?

level: middleimportance: should knowfreq 50%

answer

  1. replaying operations in memory
  2. two states compared
  3. deconstruct() of every field
  4. questions it asks you
  5. drift it cannot see

basics

~20 s

makemigrations rebuilds a project state by replaying the operations in existing migration files, builds a second state from the current models, and lets the autodetector diff the two. The database schema is never read, so manual schema changes go unnoticed.

solid answer

~40 s

The migration loader replays every operation in the migration files to build an in-memory `ProjectState`: what the models looked like after the last migration. `ProjectState.from_apps()` builds the same structure from the current model classes. `MigrationAutodetector` compares them field by field using each field's `deconstruct()` output and emits operations such as `AddField`, `AlterField` or `RenameField`. Because it compares definitions rather than the schema, a change to `help_text` or `choices` produces an `AlterField` that runs no SQL, and a column someone added by hand is invisible. When a removed and an added field have identical definitions it asks *Was workout.hr renamed to workout.avg_heart_rate?*; with `--noinput` the answer defaults to no, so the rename becomes `RemoveField` plus `AddField`.

code

python · 11 lines
python
from django.db import models


class Workout(models.Model):
    SPORTS = [('run', 'Run'), ('ride', 'Ride'), ('swim', 'Swim')]

    sport = models.CharField(max_length=10, choices=SPORTS)
    started_at = models.DateTimeField()
    duration = models.DurationField()
    # renamed from 'hr' - makemigrations asks whether this is a rename
    avg_heart_rate = models.PositiveSmallIntegerField(null=True, blank=True)

go deeper

for a junior

Remember that makemigrations compares models with what the migration files describe, not with the database.

for a middle

Explain the replayed ProjectState, ProjectState.from_apps, the deconstruct() comparison and why non-schema options produce no-op AlterFields.

for a senior

Read every generated migration for misread renames, watch for drift made outside migrations, and know how upgrades such as the 6.0 BigAutoField default surface as diffs.

for a principal

Decide how the team prevents schema drift outside migrations, given that the tooling cannot detect it by design.

## Two states, one diff `makemigrations` never asks the database what tables exist. It works purely from Python: 1. **The historical state.** The migration loader reads every migration file, builds the dependency graph, and replays each operation against an empty in-memory `ProjectState`. The result describes every model as the migrations say it should be. 2. **The current state.** `ProjectState.from_apps(apps)` builds the same structure from the model classes in your installed apps. 3. **The diff.** `MigrationAutodetector(old_state, new_state, questioner)` compares them and returns new migrations per app, which the command writes to disk. Django 5.2 added a `Command.autodetector` attribute on `makemigrations` and `migrate` so a custom command can plug in a subclass, which shows how central this class is. ## How fields are compared Each field is reduced with **`deconstruct()`** into its path and keyword arguments, such as `('django.db.models.PositiveSmallIntegerField', [], {'null': True, 'blank': True})`. Any difference produces an operation. | You change | Operation generated | SQL on apply | |---|---|---| | add a field | `AddField` | adds a column | | `max_length`, `null`, the field class | `AlterField` | alters the column | | `help_text`, `verbose_name`, `choices`, `validators`, `blank` | `AlterField` | none (`sqlmigrate` shows `-- (no-op)`) | | a field name, same definition | `RenameField` after you confirm | renames the column | | `Meta.indexes`, `Meta.constraints` | `AddIndex`, `AddConstraint` and friends | creates them | The no-op `AlterField` surprises people, but it is correct: the migration state must record every field option so that later data migrations using historical models see the right choices and validators. ## The questioner Some diffs are ambiguous, so the autodetector asks through a **questioner**: - **Renames.** A field removed and another added on the same model with identical definitions triggers *Was workout.hr renamed to workout.avg_heart_rate (a PositiveSmallIntegerField)? [y/N]*. Answering yes gives `RenameField`; no gives `RemoveField` and `AddField`, which drops the column's data. Models get the same question. - **Non-nullable additions.** Adding a `NOT NULL` field without a default asks you to provide a one-off default for existing rows or quit and add one in `models.py`. - **Non-interactive runs.** With `--noinput`, renames are answered 'no', and a prompt that cannot be resolved makes the command exit with status 3. This is why generated migrations must be read before they are committed: an unanswered rename silently becomes a data-losing drop and add. ## Reading the command's output For each migration it writes, `makemigrations` prints the file path and one line per operation, prefixed with a category symbol (the `Operation.category` attribute, added in Django 5.1): | Symbol | Meaning | Example | |---|---|---| | `+` | addition | `+ Add field avg_heart_rate to workout` | | `-` | removal | `- Remove field hr from workout` | | `~` | alteration | `~ Alter field sport on workout` | | `p` | Python code | a `RunPython` step | | `s` | raw SQL | a `RunSQL` step | Reading this summary is the fastest review: a `-` next to a `+` on the same model where you meant a rename is the signature of a declined rename question. `--dry-run` prints the same summary without writing files, and adding `--verbosity 3` prints the full file contents as well. ## What it cannot see Comparing definitions rather than schemas has consequences: - **Manual schema drift is invisible.** A column added by hand in production, or an index dropped by a database administrator, produces no operations, because the migration files never mentioned it. - **Edited migrations are trusted.** If someone changes an applied migration file, the replayed state changes too, and the next diff is computed against the edited history, not against the database. - **Unmanaged models are skipped** for schema operations: a model with `managed = False` gets a `CreateModel` in the state, but `migrate` creates no table for it. The command does open database connections, but only to check that the applied history is consistent with the graph, raising `InconsistentMigrationHistory` when a migration is recorded before its dependency. It reads no table definitions. ## Why this design Reading the state from migration files makes `makemigrations` **deterministic**: two developers with the same code and the same migration files get the same result, whatever their local databases contain. It also lets migrations run on databases that do not exist yet, such as the fresh test database, because the plan never depended on one.

  • After upgrading to Django 6.0, makemigrations wants to alter every id field; why?
    `DEFAULT_AUTO_FIELD` now defaults to `BigAutoField`. Projects that never set it, and ignored the `models.W042` warning since 3.2, had implicit `AutoField` primary keys, so the current state now differs from the migration state. Either set `DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'` to keep them, or accept the migration and plan the column type change on large tables.
  • A column was added by hand in production; will makemigrations notice?
    No. It compares the models with the state replayed from migration files, so a column no migration mentions is invisible to it. If the model also gains the field, `makemigrations` writes an `AddField`, and applying it fails because the column exists; that is the case for `SeparateDatabaseAndState` or a faked migration.

makemigrations is an architect comparing today's blueprint with the last approved blueprint in the archive, not walking through the building. If a builder knocked a wall out without paperwork, the architect never finds out.

saying these in an interview costs you the question

  • makemigrations inspects the live schema and writes the difference.
  • Changing only help_text never produces a migration.
  • Django detects field renames automatically without asking anyone.
  • With --noinput a rename still becomes a RenameField.
  • A hand-added production column shows up in the next makemigrations run.