Filter concepts by levelShowing all levels.

Django · Section 32

Migrations

Level
advanced
Read
28 min
Concepts
3

makemigrations diffs models.py against migration history and writes a new migration file, never touching the database; migrate reads migration files and actually applies them. Every migration file lists dependencies and operations, and Django builds one combined graph across every app to compute a valid apply order — a conflict (two migrations claiming the same dependency, from parallel branches) is resolved with makemigrations --merge. Built-in schema operations reverse automatically; RunPython/RunSQL data migrations need an explicit reverse or they become a one-way door blocking any rollback that reaches them. Inside RunPython, apps.get_model() returns a HISTORICAL model frozen at that migration's point in time — directly importing the real, current model is a documented anti-pattern that can break a migration re-run months later. migrate --fake marks a migration applied without running it (only when the database genuinely already matches), and squashmigrations collapses many migration files into fewer for a cleaner history.

This section

What is true here

  1. makemigrations diffs models.py into a migration file with no database interaction; migrate reads and applies migration files against the real database.
  2. Every app's migrations combine into one dependency graph — a migration can depend on a completely different app's migration, which is how cross-app relationships stay correctly ordered.
  3. A migration conflict (two migrations on the same dependency, from parallel branches) is resolved with makemigrations --merge, never by deleting one branch's file.
  4. Built-in schema operations reverse automatically; RunPython/RunSQL need an explicit reverse_code/reverse_sql or that operation (and any rollback passing through it) becomes irreversible.
  5. Inside RunPython, apps.get_model() returns the HISTORICAL model at that migration's point in time — never import the real, current model, which can silently drift and break the migration when it is re-run later.

What you will be able to do

  • Explain the makemigrations/migrate split and resolve a migration conflict correctly
  • Roll back migrations correctly, and keep RunPython/RunSQL operations reversible
  • Write a correct data migration using apps.get_model(), avoiding the historical-model trap
  • Use --fake and squashmigrations appropriately, not as a substitute for a genuine migration

makemigrations, migrate, and the graph

The diff/apply split, migration file structure, the cross-app dependency graph, and resolving conflicts.

makemigrations, migrate, and the dependency graph

coreintermediate

makemigrations compares current models against migration history and WRITES a new migration file describing the difference — it never touches the database. migrate READS migration files and actually APPLIES (or unapplies) them against the real database. Each migration file lists dependencies (which other migrations must run first) and operations (what to actually do) — Django builds an in-memory graph from every app's dependencies to compute one consistent apply order. Two developers each adding a migration on the same base, in parallel branches, produces a CONFLICT — makemigrations --merge resolves it by creating one migration depending on both.

Think of it as

makemigrations is the "diff" step — it looks at your models.py files and figures out what changed since the last migration, writing that difference down as a new Python file, entirely offline, no database connection needed. migrate is the "apply" step — it reads whatever migration files exist and actually runs their operations against a real database, tracking which ones have already been applied in its own table (django_migrations) so re-running migrate is a no-op for anything already done. The dependency graph exists because migrations rarely stand alone — a migration in one app might need another app's migration to have already run (a ForeignKey to a model in a different app), and Django computes ONE consistent, valid order across every app's migrations by treating dependencies as edges in a graph, not just a simple per-app sequence.

python
class Migration(migrations.Migration):
    dependencies = [("books", "0002_previous_migration")]
    operations = [migrations.AddField("Book", "rating", models.IntegerField(default=0))]

What we're doing: Resolve a migration conflict after two branches each independently added a migration on top of the same base migration.

shellbash
python manage.py makemigrations
# CommandError: Conflicting migrations detected; multiple leaf nodes in the
# migration graph: (0003_add_rating, 0003_add_discount in books).

python manage.py makemigrations --merge
# Created new migration books/migrations/0004_merge.py, depending on BOTH 0003s
1
The error names exactly the conflict: TWO different 0003 migrations both exist, both claiming 0002 as their dependency — the graph has two "leaf" branches instead of one.
4
--merge writes a new migration whose dependencies list includes BOTH conflicting 0003 migrations, giving the graph a single, unambiguous path forward again.

Why this works: This conflict is an entirely normal consequence of two developers working in parallel branches, each running makemigrations against the same starting point — Django cannot silently guess which of the two "goes first" (they might even touch unrelated fields), so it stops and asks explicitly, and --merge is the documented, safe way to reconcile them into one valid history.

Deleting one branch's migration file to "resolve" a conflict, instead of merging

Wrong

bash
# "just delete the other branch's migration, mine is more important"
rm books/migrations/0003_add_discount.py
git commit -m "resolved migration conflict"

Better

bash
python manage.py makemigrations --merge
git add books/migrations/0004_merge.py

What you see: The deleted migration's actual schema change (e.g. the discount field) never gets applied to any database going forward, even though the model field it was meant to create might still exist in models.py — a silent, hard-to-trace drift between the model definitions and the migration history.

Why: Deleting a migration file does not remove whatever model changes it was meant to apply — if the corresponding model field is still present in models.py, a future makemigrations run may generate a confusing, unrelated-looking migration trying to "add" a field that was actually already intended to exist; --merge is the tool built specifically to reconcile two real, both-needed migrations into one consistent history, not to discard one arbitrarily.

makemigrations writes a file; migrate applies it

models.py

current model definitions

makemigrations

diffs against migration history — no DB touched

0003_add_rating.py

dependencies + operations

migrate

reads the file, applies it

database schema

  • models.py — current model definitions
    • leads to makemigrations
  • makemigrations — diffs against migration history — no DB touched
    • leads to 0003_add_rating.py
  • 0003_add_rating.py — dependencies + operations
    • leads to migrate
  • migrate — reads the file, applies it
    • leads to database schema
  • database schema

makemigrations vs migrate

makemigrations vs migrate
CommandReadsWrites
makemigrationscurrent models.py files + existing migration historya new migration .py file
migratemigration files + the django_migrations tracking tablethe actual database schema

Together

bash
python manage.py makemigrations       # writes books/migrations/0003_add_rating.py
python manage.py migrate               # actually adds the "rating" column to the database
python manage.py showmigrations         # lists every migration and whether it's applied

Remember: makemigrations writes a migration file from a models.py diff (no database touched); migrate reads migration files and actually applies them. A migration's dependencies (which can cross app boundaries) plus every app's combined graph determine one consistent apply order. A conflict (two migrations claiming the same dependency) is resolved with makemigrations --merge, not by deleting a file.

See also: forward reverse and irreversible operations · data migrations fake and squashing · the expand and contract technique

Advertisement

Forward and reverse migrations

Rolling migrations forward and backward, and what makes an operation irreversible.

Forward and reverse migrations

coreintermediate

migrate app_name applies every migration up to the latest (forward). migrate app_name 0002 rolls back to (and including) that specific migration — Django figures out which already-applied migrations need to be UNDONE and runs each one's reverse operation. migrate app_name zero rolls back every migration for that app entirely. Not every operation can be reversed — RunSQL with no reverse_sql, or a RunPython with no reverse function, raises IrreversibleError the moment a rollback tries to pass through it.

Think of it as

Every schema-changing operation Django knows about (AddField, CreateModel, etc.) has a built-in, automatic reverse — Django can compute "how do I undo adding this column" without being told. RunPython and RunSQL are different: they are arbitrary code/SQL, and Django has no way to invent an opposite for an arbitrary operation on its own — reversibility for those two has to be supplied explicitly (a reverse_code function, a reverse_sql string), or the migration is a one-way door. Rolling back is not "time travel that undoes history" — it is genuinely RE-RUNNING each already-applied migration's reverse operation, in reverse order, so a migration missing a reverse for even one operation blocks the entire rollback the moment it's reached.

python
migrations.RunSQL(
    sql="ALTER TABLE books ADD COLUMN rating integer",
    reverse_sql="ALTER TABLE books DROP COLUMN rating",   # required for this migration to be reversible
)

What we're doing: Write a RunSQL data migration that stays reversible, by supplying an explicit reverse_sql rather than leaving it a one-way door.

books/migrations/0005_backfill_slug.pypython
operations = [
    migrations.RunSQL(
        sql="UPDATE books_book SET slug = lower(replace(title, ' ', '-')) WHERE slug IS NULL",
        reverse_sql="UPDATE books_book SET slug = NULL WHERE slug IS NOT NULL",
    ),
]
3
sql runs on a FORWARD migrate — backfilling slug from title for every row missing one.
4
reverse_sql runs if this migration is ever rolled back — without it, this migration (and every migration rollback that needs to pass through it) would raise IrreversibleError.

Why this works: A migration without a working reverse becomes a permanent, one-way constraint on the entire migration history — anyone needing to roll back past this point (a bad deploy, a local dev environment reset) hits a hard wall, even if the actual reversal (clearing the backfilled column) is trivial to express.

Assuming migrate app_name zero (or a rollback target) will always work, without checking for irreversible operations first

Wrong

bash
python manage.py migrate books zero
# django.db.migrations.exceptions.IrreversibleError: Operation ... in books.0005_backfill_slug is not reversible

Better

python
# before writing a RunPython/RunSQL migration, always supply a reverse:
migrations.RunPython(backfill_slug, reverse_code=clear_slug)

What you see: A rollback that a runbook or CI step assumed would "just work" fails partway through, on whichever migration happens to lack a reverse — often discovered only during an actual incident, when a rollback is urgently needed.

Why: IrreversibleError is raised the moment a rollback's path passes through an operation with no defined reverse — this is entirely predictable from reading the migration files themselves, which is exactly why reversibility should be a deliberate decision made at AUTHORING time (supply reverse_code/reverse_sql, or explicitly accept the migration is one-way), not discovered under pressure during an actual rollback attempt.

Forward and reverse migrate, and where an irreversible operation blocks
migrate→ 0002migrate→ 0003migrate(forward)migrate 0002 (reverse)— IrreversibleError

zero

start

0002

0003 (RunSQL, no reverse_sql)

latest

end

  • zero (start)
    • → 0002 when migrate → 0002
  • 0002
    • → 0003 (RunSQL, no reverse_sql) when migrate → 0003
  • 0003 (RunSQL, no reverse_sql)
    • → latest when migrate (forward)
    • → 0002 when migrate 0002 (reverse) — IrreversibleError
  • latest (end)

Forward vs reverse migration commands

Forward vs reverse migration commands
CommandEffect
migrate app_nameapply every unapplied migration, forward, up to the latest
migrate app_name 0002roll back to (including) 0002 — later migrations get reversed
migrate app_name zerounapply every migration for this app entirely

Together

bash
python manage.py migrate books           # forward — applies everything up to the latest
python manage.py migrate books 0002       # reverse — rolls back to (and including) 0002
python manage.py migrate books zero        # reverse — unapplies everything

Remember: migrate app_name applies forward; migrate app_name <target> (or zero) rolls back, by running each already-applied migration's REVERSE operation. Built-in schema operations reverse automatically; RunPython/RunSQL need an explicit reverse (reverse_code/reverse_sql) or they become a one-way door that blocks any rollback trying to pass through them.

See also: makemigrations migrate and the graph · data migrations fake and squashing · dangerous schema changes

Advertisement

Data migrations, fake, and squashing

RunPython/RunSQL and the historical-model rule, plus the two commands that manage migration history itself rather than the schema.

RunPython/RunSQL, fake migrations, and squashing

coreadvanced

A SCHEMA migration changes table structure (AddField, CreateModel); a DATA migration changes the actual row VALUES (via RunPython or RunSQL), often to backfill a new column. RunPython MUST get its models via apps.get_model("app", "Model") — a HISTORICAL, frozen-in-time model matching that exact point in migration history — never the real, current models.py import, which can drift out of sync with what the migration was actually written against. migrate --fake marks a migration as applied WITHOUT running it (for a database that already matches); squashmigrations collapses many migration files into fewer, for a cleaner history.

Think of it as

apps.get_model() exists because a migration file, once written, is frozen forever — but the REAL models.py keeps evolving after that. If a RunPython operation imported the real, current Author model directly, a field that model has TODAY (but didn't have when this migration was written) would appear in a migration meant to represent an EARLIER point in history — re-running that same migration against a fresh database, months later, could behave completely differently than it did when first applied, since it's now interacting with a model shape that didn't exist yet at the migration's actual point in time. apps.get_model() sidesteps this by handing back a RECONSTRUCTED model matching exactly what the schema looked like at THIS migration's position in the graph, which is why it is a load-bearing rule, not a style preference. --fake and squashmigrations are both about the RECORD of what happened, not what a migration does: --fake tells Django "trust me, this migration's effect is already true, just mark it as done," and squashing rewrites many small, real migration files into fewer files with the same net effect, purely for a cleaner history going forward.

bash
python manage.py migrate --fake-initial   # adopting migrations on an existing database
python manage.py squashmigrations books 0004

What we're doing: Write a correct data migration that backfills a new field, using the historical model rather than a direct import that could drift out of sync.

people/migrations/0005_backfill_full_name.pypython
def backfill_full_name(apps, schema_editor):
    Person = apps.get_model("people", "Person")
    for person in Person.objects.all():
        person.full_name = f"{person.first_name} {person.last_name}".strip()
        person.save(update_fields=["full_name"])

class Migration(migrations.Migration):
    dependencies = [("people", "0004_add_full_name_field")]
    operations = [migrations.RunPython(backfill_full_name)]
2
apps.get_model("people", "Person") returns a HISTORICAL Person model, matching exactly the fields that existed at migration 0004 — not whatever fields the real Person model has today, which might include several more added since.

Why this works: If this migration instead did `from people.models import Person`, running it fresh against a brand-new database, long after Person had grown several more required fields, could break — the migration was written expecting only first_name/last_name/full_name to exist, and the real, current model might now have other required fields this migration knows nothing about.

Importing the real, current model directly inside a RunPython function instead of using apps.get_model()

Wrong

python
from people.models import Person   # the REAL, current model — a documented anti-pattern

def backfill_full_name(apps, schema_editor):
    for person in Person.objects.all():
        person.full_name = f"{person.first_name} {person.last_name}"
        person.save()

Better

python
def backfill_full_name(apps, schema_editor):
    Person = apps.get_model("people", "Person")   # historical, frozen at this migration's point in history
    for person in Person.objects.all():
        person.full_name = f"{person.first_name} {person.last_name}"
        person.save()

What you see: A migration that worked fine when first written and applied suddenly breaks — with a confusing error about a missing or unexpected field/method — when re-run against a fresh database MONTHS later, after the real Person model has since changed (a new required field added, a custom method removed, a manager's default queryset changed).

Why: Every developer's local environment, CI pipeline, and any newly-provisioned server all re-run EVERY migration from the beginning — a migration importing the real, current model is silently coupled to whatever that model looks like TODAY, not what it looked like at the migration's actual point in history, which is precisely the mismatch apps.get_model()'s historical reconstruction exists to prevent.

apps.get_model() vs a direct import, inside RunPython

apps.get_model("app", "Model")

  • +A HISTORICAL model, frozen at this migration's point
  • +Safe to re-run months later
  • +The documented, correct way

from app.models import Model

  • The REAL, current model
  • Drifts out of sync as models.py evolves
  • A documented anti-pattern
  • apps.get_model("app", "Model")
    • A HISTORICAL model, frozen at this migration's point
    • Safe to re-run months later
    • The documented, correct way
  • from app.models import Model
    • The REAL, current model
    • Drifts out of sync as models.py evolves
    • A documented anti-pattern

Data vs schema migrations, and the two special-case commands

Data vs schema migrations, and the two special-case commands
ConceptWhat it does
Schema migrationchanges table structure (AddField, CreateModel, ...)
Data migration (RunPython/RunSQL)changes actual row values — often backfilling a new column
migrate --fakemarks a migration applied WITHOUT running it — the database must already match
squashmigrationscollapses many migration files into fewer, for a cleaner history

Together

python
def backfill_full_name(apps, schema_editor):
    Person = apps.get_model("people", "Person")   # HISTORICAL model — correct
    for person in Person.objects.all():
        person.full_name = f"{person.first_name} {person.last_name}"
        person.save()

class Migration(migrations.Migration):
    dependencies = [("people", "0004_add_full_name_field")]
    operations = [migrations.RunPython(backfill_full_name)]

Remember: A data migration (RunPython/RunSQL) changes row values, distinct from a schema migration changing structure. RunPython must use apps.get_model() for a HISTORICAL model — never import the real, current model, which can drift and break a migration re-run later. --fake marks a migration applied without running it (only when the database genuinely already matches); squashmigrations collapses history into fewer files for the same net effect.

See also: forward reverse and irreversible operations · makemigrations migrate and the graph · the expand and contract technique

Advertisement