How Django Migrations Actually Work: From makemigrations to migrate
A deep dive into how Django migrations really work how makemigrations detects model changes, how the migration graph and autodetector operate, how migrate turns operations into SQL via the SchemaEdito
On this page
- The Core Problem Migrations Solve
- Migrations solve three important problems
- The Two Model States
- State A — The previous project state
- State B — The current model state
- What the Autodetector Can Detect
- Adding a field
- Removing a field
- Changing a field
- Renaming a field
- dependencies
- operations
- Why a Graph?
- Step 1 — Django Reads the Migration History
- Step 2 — Django Builds the Migration Plan
- Migration files
- Applied migrations
- Step 3 — Django Executes Migration Operations
- PostgreSQL
- MySQL
- SQLite
- Step 4 — Django Uses Transactions Where Supported
- Step 5 — Django Records the Migration
- 1. Database state
- 2. Project state
- Migration 1 — Add the field as nullable
- Migration 2 — Populate existing records
- Migration 3 — Make the field required
- "No changes detected"
- 1. You modify the model
- 2. You run makemigrations
- 3. You commit the migration
- 4. Someone runs migrate
- 5. Django executes the operation
- 6. The database executes the SQL
- 7. Django records the migration
- 8. The next migrate skips it
How Django Migrations Actually Work: From makemigrations to migrate#
Every Django developer has typed:
python manage.py makemigrations
python manage.py migratehundreds of times.
It can feel like magic. You change a model, run two commands, and somehow your database schema is updated.
But what actually happens between those two commands?
Where do migrations come from? How does Django know what changed? How does it decide which migrations to run? And how does a Python migration file eventually become SQL executed against your database?
This article walks through the entire Django migration pipeline — from the moment you edit a model to the moment SQL reaches your database — and explains the machinery underneath it.
The Core Problem Migrations Solve#
Django models are written in Python:
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
Your database, however, doesn't understand Python models.
A relational database understands SQL, including DDL (Data Definition Language) statements such as:
CREATE TABLE
ALTER TABLE
ADD COLUMN
DROP COLUMN
CREATE INDEX
Django migrations act as the bridge between these two worlds.
Django Models
│
│ makemigrations
▼
Migration Files
│
│ migrate
▼
Database Schema
Migrations describe database changes using Python objects. Django then translates those operations into the appropriate SQL for your database backend.
This allows the same migration to work with databases such as:
- - PostgreSQL
- - MySQL
- - SQLite
- - Oracle
Migrations solve three important problems#
1. Version control for your schema#
Migration files can be committed to Git alongside your application code.
Git Repository
│
├── models.py
└── migrations/
├── 0001_initial.py
├── 0002_book_published_date.py
└── 0003_book_isbn.py
Your database structure changes therefore become part of your application's version history.
2. Reproducibility#
Another developer can clone your project and run:
python manage.py migrate
Django can then build the database schema by applying the migration history.
3. Incremental changes#
Django keeps track of which migrations have already been applied.
If your database has already applied:
0001_initial
0002_book_published_date
and you add:
0003_book_isbnDjango doesn't recreate everything.
It only needs to apply 0003_book_isbn.
The Two Halves: makemigrations vs migrate#
One of the most important concepts to understand is that these two commands do completely different jobs.
| Command | What it uses | What it produces |
|---|---|---|
| makemigrations | Your current Python models + migration history | Migration files |
| migrate | Migration files + database migration history | Database schema changes |
A simplified view looks like this:
models.py
│
│ makemigrations
▼
migration files
│
│ migrate
▼
databaseThere is an important distinction here:
makemigrationsdoes not normally modify your database schema.
It creates migration files.
On the other hand:
migratedoes not compare your currentmodels.pydirectly with the database to generate changes.
It works from the migration files and the migration history stored in the database.
Understanding this separation is the key to understanding Django migrations.
Part 1: How makemigrations Detects Changes#
Suppose you initially have:
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)You already have:
0001_initial.pyThen you add:
published_date = models.DateField(null=True)Your model becomes:
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
published_date = models.DateField(null=True)Now you run:
python manage.py makemigrationsHow does Django know that published_date was added?
It compares two model states.
The Two Model States#
Django essentially needs to answer:
"What did my models look like before, and what do they look like now?"
State A — The previous project state#
Django loads the existing migration files and reconstructs the model state represented by those migrations.
For example:
0001_initial.py
│
▼
Project State
│
├── Book
├── title
└── authorThis state is reconstructed from migration files.
It isn't simply read from your current database schema.
State B — The current model state#
Django also loads your current models from the installed applications.
Your current model contains:
Book
├── title
├── author
└── published_dateDjango now has two states:
Previous State Current State
Book Book
├── title ├── title
└── author ├── author
└── published_dateSomething changed.
Now Django needs to determine exactly what operation represents that change.
The Migration Autodetector#
Django uses the migration autodetector to compare the two states.
Internally, this logic lives in:
django.db.migrations.autodetector.MigrationAutodetectorThe autodetector calculates the operations required to transform the old state into the new state.
In our example:
Previous State
│
│
│ autodetector
▼
Current State
The result is approximately:
migrations.AddField(
model_name="book",
name="published_date",
field=models.DateField(null=True),
)
Django then writes that operation into a new migration file.
What the Autodetector Can Detect#
The autodetector handles many different kinds of model changes.
Adding a field#
published_date = models.DateField(null=True)becomes an AddField operation.
Removing a field#
migrations.RemoveField(...)
Changing a field#
For example:
title = models.CharField(max_length=200)to:
title = models.CharField(max_length=300)can produce an AlterField operation.
Renaming a field#
Suppose you change:
pub_dateto:
published_dateDjango cannot always know whether you actually renamed the field or simply deleted one field and created another.
It may therefore ask:
Did you rename book.pub_date to book.published_date? [y/N]If you confirm, Django can generate a RenameField operation.
This is important because a rename can preserve existing data, while deleting and recreating a column could lose it.
Dependencies and Ordering#
Migrations can depend on other migrations.
For example, suppose:
app_a
└── 0002_create_author.py
app_b
└── 0002_create_book.pyand Book has a foreign key to Author.
Django needs to make sure the Author table exists before creating the relationship from Book.
Migration dependencies allow Django to express that relationship.
Django can therefore determine an appropriate execution order even when multiple applications are involved.
Adding Non-Nullable Fields#
Another common situation occurs when you add a required field to an existing model.
Suppose you have:
class Book(models.Model):
title = models.CharField(max_length=200)and change it to:
class Book(models.Model):
title = models.CharField(max_length=200)
isbn = models.CharField(max_length=20)The new field is required by default.
But your database may already contain thousands of rows.
What should happen to the existing rows?
For example:
Existing rows
Book 1 → title = "Django Basics"
Book 2 → title = "Learning Python"
Book 3 → title = "Effective APIs"
Django needs to know what value should be inserted into isbn for these existing rows.
That's why Django may ask you for a one-off default.
For large or important production tables, however, you often want a more deliberate migration strategy. We'll look at that later when discussing data migrations.
The Output: A Migration File#
After running:
python manage.py makemigrationsDjango might generate:
myapp/
└── migrations/
├── __init__.py
├── 0001_initial.py
└── 0002_book_published_date.pyThe new migration might look like:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("myapp", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="book",
name="published_date",
field=models.DateField(null=True),
),
]
Two parts are especially important.
dependencies#
dependencies = [
("myapp", "0001_initial"),
]This tells Django:
0002_book_published_datedepends on0001_initial.
Therefore, 0001_initial must be applied before 0002_book_published_date.
These dependencies are what Django uses to build the migration graph.
operations#
operations = [
migrations.AddField(...),
]These are the actual changes represented by the migration.
Notice something important:
There is no SQL here.
You don't see:
ALTER TABLE ...Instead, the migration contains a database-agnostic operation.
Django will later translate that operation into SQL appropriate for the database backend.
Part 2: The Migration Graph#
Migration files are not simply a list of files that Django executes from top to bottom.
They form a directed acyclic graph (DAG).
Consider:
0001
│
▼
0002
│
├──────────────┐
▼ ▼
0003_a 0003_b
│ │
└──────┬───────┘
▼
0004
Each migration points to its dependencies.
Django uses these relationships to construct the complete migration graph across your installed applications.
Why a Graph?#
Imagine you have three applications:
users
orders
paymentsAn Order might depend on a User.
A Payment might depend on an Order.
That gives Django relationships such as:
users.0001
│
▼
orders.0001
│
▼
payments.0001
A simple list cannot accurately represent all these relationships.
A graph can.
Django can then determine a valid execution order that respects all dependencies.
Migration Leaves and Conflicts#
A leaf is a migration that currently has no other migration depending on it.
Normally, an application has one latest migration:
0001
│
▼
0002
│
▼
0003
0003 is the leaf.
But consider two developers working from the same migration:
0002
/ \
▼ ▼
0003_alice 0003_bob
Developer A creates:
0003_alice.pyDeveloper B independently creates:
0003_bob.pyNow there are two migration leaves.
Django detects the conflict.
You can merge them with:
python manage.py makemigrations --mergeDjango creates a merge migration that depends on both branches:
0002
/ \
▼ ▼
0003_alice 0003_bob
\ /
▼ ▼
0004
The graph has been reunited.
Part 3: How migrate Applies Changes#
Now we reach the second command:
python manage.py migrateThis is where Django actually changes your database.
The process can be simplified to:
Migration Files
│
▼
Migration Graph
│
▼
django_migrations
│
▼
Migration Plan
│
▼
Migration Operations
│
▼
SchemaEditor
│
▼
SQL
│
▼
Database
Let's break that down.
Step 1 — Django Reads the Migration History#
Django maintains a special database table:
django_migrationsIt records which migrations have already been applied.
A simplified version might look like:
| id | app | name | applied |
|---|---|---|---|
| 1 | myapp | 0001_initial | 2026-01-10 09:00 |
| 2 | myapp | 0002_book_published_date | 2026-01-11 14:30 |
This table is one of the key pieces of Django's migration system.
Django can look at this table and determine:
0001_initial → Applied
0002_book_published_date → Applied
0003_book_isbn → Not applied
If the table doesn't exist yet, Django creates the necessary migration infrastructure when setting up a new database.
Step 2 — Django Builds the Migration Plan#
Django now has two pieces of information:
Migration files#
0001
0002
0003Applied migrations#
0001
0002Therefore:
Pending:
0003Django creates an execution plan containing the migrations that need to be applied while respecting their dependencies.
You can inspect migrations with:
python manage.py showmigrationsYou might see:
myapp
[X] 0001_initial
[X] 0002_book_published_date
[ ] 0003_book_isbn[X] means applied.
[ ] means pending.
You can also inspect the migration plan without applying it:
python manage.py migrate --planThis is particularly useful before running migrations in a production environment.
Step 3 — Django Executes Migration Operations#
Now Django has something like:
migrations.AddField(
model_name="book",
name="published_date",
field=models.DateField(null=True),
)But your database doesn't understand AddField.
This is where the SchemaEditor comes in.
Each supported database backend provides a schema editor capable of translating Django migration operations into database-specific SQL.
Conceptually:
AddField
│
▼
SchemaEditor
│
├── PostgreSQL
├── MySQL
├── SQLite
└── Oracle
PostgreSQL#
For PostgreSQL, an operation might produce SQL similar to:
ALTER TABLE "myapp_book"
ADD COLUMN "published_date" date NULL;MySQL#
MySQL has different SQL syntax and type handling.
Django's MySQL backend takes care of those differences.
SQLite#
SQLite has historically had more limited ALTER TABLE capabilities than databases such as PostgreSQL.
For certain schema changes, Django may need to use a table-rebuild strategy:
Create new table
│
▼
Copy existing data
│
▼
Remove old table
│
▼
Rename new table
You don't need to write this database-specific logic yourself.
That's one of the major benefits of Django's migration abstraction.
Seeing the SQL Before Running It#
You can inspect the SQL Django expects a migration to execute:
python manage.py sqlmigrate myapp 0002For example, you might see:
BEGIN;
ALTER TABLE "myapp_book"
ADD COLUMN "published_date" date NULL;
COMMIT;
This is extremely useful when reviewing migrations before production deployment.
Step 4 — Django Uses Transactions Where Supported#
Django can wrap migrations in transactions when the database backend supports transactional DDL.
PostgreSQL is a good example.
Conceptually:
BEGIN
│
├── SQL operation 1
├── SQL operation 2
└── SQL operation 3
│
▼
COMMIT
If an operation fails:
BEGIN
│
├── SQL operation 1 ✓
├── SQL operation 2 ✓
└── SQL operation 3 ✗
│
▼
ROLLBACK
This helps prevent a migration from leaving the database in a partially changed state.
However, transactional behavior depends on the database backend and migration configuration.
You can also disable the migration-level transaction wrapper with:
class Migration(migrations.Migration):
atomic = False
This is useful for certain operations that cannot run inside a transaction, such as some PostgreSQL concurrent index operations.
Step 5 — Django Records the Migration#
After the migration successfully completes, Django records it in:
django_migrationsFor example:
myapp | 0003_book_isbn | 2026-01-12 10:15Now Django knows:
0003_book_isbn → AppliedIf you run:
python manage.py migrateagain, Django sees that the migration is already recorded and skips it.
The Two Kinds of State#
There is another subtle concept that is important when understanding Django migrations.
Migration operations affect two kinds of state:
Migration Operation
│
┌──────────┴──────────┐
▼ ▼
Database Schema Project State
1. Database state#
This is the actual database structure:
Database
└── myapp_book
├── id
├── title
├── author
└── published_date
The SchemaEditor is responsible for applying the database-side changes.
2. Project state#
Django also maintains an in-memory representation of what the models look like at a particular point in migration history.
This matters because later migrations may need to work with the historical version of a model.
This becomes especially important with data migrations.
Data Migrations: Changing Data Instead of Structure#
Not every migration changes the database structure.
Sometimes you need to modify existing data.
For example, suppose you add:
slug = models.SlugField(null=True)to an existing Article model.
You might want to populate the slug based on the article title.
That's a data migration.
Django provides RunPython for this.
from django.db import migrations
from django.utils.text import slugify
def populate_slugs(apps, schema_editor):
Article = apps.get_model("blog", "Article")
for article in Article.objects.all():
article.slug = slugify(article.title)
article.save(update_fields=["slug"])
def reverse_slugs(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("blog", "0005_article_slug"),
]
operations = [
migrations.RunPython(
populate_slugs,
reverse_slugs,
),
]
Why apps.get_model() Matters#
Inside a data migration, you should normally use:
Article = apps.get_model("blog", "Article")rather than:
from blog.models import ArticleWhy?
Because the migration should operate against the historical version of the model that exists at that point in the migration history.
Imagine this:
0001 → Article.title
0002 → Article.slug
0003 → Article.description
0004 → Remove Article.title
A data migration at 0002 needs to understand the model as it existed at 0002, not what the model looks like today.
Using:
apps.get_model(...)gives Django the historical model state appropriate for that migration.
This is an important part of making migrations reproducible.
A Safe Pattern for Adding Required Data#
Suppose you need to add a required field to a table that already contains millions of rows.
Instead of immediately adding:
email = models.EmailField()you can split the change into several migrations.
Migration 1 — Add the field as nullable#
email = models.EmailField(null=True)Migration 2 — Populate existing records#
migrations.RunPython(populate_emails)Migration 3 — Make the field required#
email = models.EmailField(null=False)The process becomes:
Add nullable field
│
▼
Backfill existing data
│
▼
Validate / clean data
│
▼
Make field non-nullable
This pattern gives you more control and can be much safer for production systems.
Reversing Migrations#
Django migrations are generally designed to be reversible.
Suppose your migration history is:
0001
│
▼
0002
│
▼
0003
│
▼
0004
You want to return to the state after 0002.
Run:
python manage.py migrate myapp 0002Django will reverse:
0004
↓
0003until the database reaches the state represented by 0002.
You can also unapply all migrations for an application:
python manage.py migrate myapp zeroThat tells Django to migrate the application back to the state before its first migration.
Not Every Migration Is Reversible#
Some migration operations cannot automatically be reversed.
For example:
migrations.RunPython(
populate_data,
)If you don't provide a reverse operation, Django may raise:
IrreversibleErrorSimilarly, custom SQL may need explicit reverse SQL.
For example:
migrations.RunSQL(
sql="...",
reverse_sql="...",
)When writing production migrations, always think about whether the operation should be reversible and what happens if you need to roll it back.
Common Django Migration Problems#
Once you understand the internal process, many common migration errors become easier to understand.
"No changes detected"#
You changed a model and ran:
python manage.py makemigrationsbut Django says:
No changes detectedCommon causes include:
- - The application isn't in
INSTALLED_APPS. - - The model isn't registered correctly.
- - The model is in a location Django isn't loading.
- - The migration package is missing or incorrectly configured.
- - The change isn't actually different from the migration state.
- Remember that
makemigrationscompares Django's model state, so the model must be part of the installed application configuration.
--fake and --fake-initial#
You may eventually encounter:
python manage.py migrate --fakeand:
python manage.py migrate --fake-initialThese commands are related to migration history rather than actually performing the corresponding database operations.
For example:
python manage.py migrate myapp 0002 --fakerecords the migration as applied without executing its database operations.
This can be useful when the database schema already matches the migration.
But be careful.
Using --fake effectively tells Django:
"The database already has the changes represented by this migration."
If that isn't true, Django's migration history and actual database schema can become inconsistent.
Squashing Migrations#
As a project grows, an application can accumulate many migration files:
0001_initial.py
0002_add_name.py
0003_add_email.py
0004_change_name.py
0005_remove_old_field.py
...
0100_add_index.pyDjango provides:
python manage.py squashmigrationsto combine a range of migrations into fewer migrations.
For example:
python manage.py squashmigrations myapp 0001 0100Conceptually:
0001
0002
0003
...
0100
│
│ squash
▼
0001_squashed_0100Squashing can make migration history easier to manage and can reduce the amount of migration work Django needs to process in some situations.
However, you shouldn't simply delete old migration files from production without understanding the migration history of every environment.
A safer production workflow is:
Create squashed migration
│
▼
Commit old + squashed migrations
│
▼
Deploy to all environments
│
▼
Allow environments to transition
│
▼
Remove replaced migrations laterMigration Conflicts on a Team#
Two developers can independently create migrations from the same parent:
0002
/ \
▼ ▼
0003_alice 0003_bob
This creates multiple migration leaves.
You can resolve the conflict using:
python manage.py makemigrations --mergeThe resulting graph becomes:
0002
/ \
▼ ▼
0003_alice 0003_bob
\ /
▼ ▼
0004
The important point is that migration conflicts are fundamentally graph problems.
Slow Migrations in Production#
A migration isn't just a Python file sitting in your repository.
When you run:
python manage.py migrateDjango eventually executes real database operations.
For a small table, this might be almost instantaneous.
For a table containing millions of rows, an operation such as:
ALTER TABLE ...can potentially take significant time or acquire locks that affect application traffic.
This is why production migrations deserve careful review.
Before deployment, you can inspect the generated SQL:
python manage.py sqlmigrate myapp 0005and inspect the migration plan:
python manage.py migrate --planFor PostgreSQL, some operations may also require special techniques, such as creating indexes concurrently.
In those situations, you may need:
class Migration(migrations.Migration):
atomic = FalseThe exact strategy depends on the operation, database version, table size, and traffic pattern.
The Full Pipeline, End to End#
Let's trace one simple change from beginning to end.
Suppose we start with:
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)Then we add:
published_date = models.DateField(null=True)Here's what happens.
1. You modify the model#
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
published_date = models.DateField(null=True)2. You run makemigrations#
python manage.py makemigrationsDjango:
- - Loads the existing migration files.
- - Reconstructs the previous project state.
- - Loads the current model state.
- - Compares the two states using the migration autodetector.
- - Generates an
AddFieldoperation. - Writes the operation to a new migration file.
You now have:
0002_book_published_date.py3. You commit the migration#
You commit the migration file to Git:
git add .
git commit -m "Add published date to books"
Your schema change is now version-controlled.
4. Someone runs migrate#
python manage.py migrateDjango:
- - Loads the migration files.
- - Builds the migration graph.
- - Reads
django_migrations. - - Determines which migrations are already applied.
- - Builds the migration plan.
- - Finds
0002_book_published_dateas pending.
5. Django executes the operation#
The migration contains:
migrations.AddField(...)Django passes that operation through the database backend's SchemaEditor.
For PostgreSQL, this may become SQL similar to:
ALTER TABLE "myapp_book"
ADD COLUMN "published_date" date NULL;6. The database executes the SQL#
The database schema now contains:
myapp_book
├── id
├── title
├── author
└── published_date7. Django records the migration#
Django inserts a record into:
django_migrationsNow:
0002_book_published_date → Applied8. The next migrate skips it#
If someone runs:
python manage.py migrateagain, Django sees that 0002_book_published_date is already recorded.
It doesn't execute it again.
The Complete Mental Model#
At this point, you can think about Django migrations as three connected layers:
┌───────────────────────────────┐
│ Django Models │
│ │
│ models.py │
└───────────────┬───────────────┘
│
│ makemigrations
▼
┌───────────────────────────────┐
│ Migration History │
│ │
│ 0001_initial.py │
│ 0002_add_field.py │
│ 0003_add_index.py │
└───────────────┬───────────────┘
│
│ migrate
▼
┌───────────────────────────────┐
│ Database │
│ │
│ Actual SQL schema │
│ │
│ django_migrations │
└───────────────────────────────┘
The two commands have different responsibilities:
makemigrations
│
├── Reads migration history
├── Reads current models
├── Detects differences
└── Writes migration files
migrate
│
├── Reads migration files
├── Reads django_migrations
├── Builds migration plan
├── Executes operations
└── Records applied migrations
Once you understand this separation, Django's migration system becomes much less mysterious.
Final Thoughts#
Django migrations may look like simple Python files, but underneath them is a fairly sophisticated system.
The process is roughly:
Edit models.py
│
▼
makemigrations
│
▼
Compare model states
│
▼
Migration operations
│
▼
Migration files
│
▼
Migration graph
│
▼
migrate
│
▼
Migration plan
│
▼
SchemaEditor
│
▼
Database-specific SQL
│
▼
Database schema
│
▼
django_migrations
The most important thing to remember is that Django doesn't magically synchronize your models and database.
Instead, it maintains a versioned history of changes.
makemigrations figures out what changed and records that change as a migration.
migrate figures out which recorded changes still need to be applied, converts those operations into database-specific SQL, executes them, and records the result.
Once you understand that pipeline, commands like --fake, --merge, --plan, sqlmigrate, data migrations, and migration squashing become much easier to reason about.
And more importantly, when a migration fails in production, you have a mental model for understanding where in the pipeline the problem actually occurred.
Discussion
Loading the discussion…
Related articles
- How to Safely Rename a Django Model or Database FieldA practical, step-by-step guide to renaming Django models and fields without losing data or breaking production — covering RenameModel, RenameField, ForeignKeys, and zero-downtime strategies.Sep 15, 2026 · Django Migration
- Django Query Optimization: How to Make Your API FasterYour Django code may look fast, while your database is doing hundreds of unnecessary queries. Learn how to find the real bottlenecks and make your Django APIs faster with practical query optimization.Sep 14, 2026 · Django
- How to Clean Up Django Migration Files Before ProductionLearn how to safely clean and squash Django migration files before production, with practical examples and a beginner-friendly step-by-step workflow.Sep 14, 2026 · Django