Migrations Deep Dive: makemigrations & migrate

Reviewed & published by Brayan K

A migration is a versioned Python file that records a change to your models so Django can apply that exact change to the database schema in the right order, on every machine.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll see exactly what migration files contain, how the migration graph orders them through dependencies, how to move data with RunPython, and how the escape hatches --fake, sqlmigrate, and squashing fit in.

What You'll Learn in This Lesson

1 makemigrations vs migrate

Working with the schema is a two-step dance. makemigrations compares your models.py to the last known state and writes a new migration file describing the difference — it never touches the database. migrate then reads the unapplied migration files and runs their SQL against the database. Think plan, then apply.

# 1. You add a field to a model
# blog/models.py
class Post(models.Model):
    title = models.CharField(max_length=200)
    views = models.IntegerField(default=0)   # <-- new field

# 2. Plan the change: writes blog/migrations/0002_post_views.py
python manage.py makemigrations
# Migrations for 'blog':
#   blog/migrations/0002_post_views.py
#     - Add field views to post

# 3. Apply the change to the database
python manage.py migrate
# Applying blog.0002_post_views... OK

The generated file is plain, readable Python — a list of operations and the migrations it depends on:

# blog/migrations/0002_post_views.py
from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [
        ("blog", "0001_initial"),
    ]
    operations = [
        migrations.AddField(
            model_name="post",
            name="views",
            field=models.IntegerField(default=0),
        ),
    ]
# A migration is just a recorded operation that Django replays in order.
# Here is a tiny state machine that mirrors makemigrations + migrate:
# the schema starts empty, each migration adds a field, and migrate
# applies only the ones not yet recorded in "applied".

schema = {"post": ["id", "title"]}     # current columns
applied = []                           # like the django_migrations table

# makemigrations produced these files (name -> operation)
migrations = [
    ("0002_post_views", ("post", "views")),
    ("0003_post_slug",  ("post", "slug")),
]

def migrate(migrations, schema, applied):
    for name, (table, column) in migrations:
        if name in applied:
            print(f"  {name}... already applied, skipping")
            continue
        schema[table].append(column)
        applied.append(name)
        print(f"  Applying {name}... OK")

print("Running migrate:")
migrate(migrations, schema, applied)

print("Running migrate again (nothing new):")
migrate(migrations, schema, applied)

print("Final columns:", schema["post"])

# EXPECTED:
# Running migrate:
#   Applying 0002_post_views... OK
#   Applying 0003_post_slug... OK
# Running migrate again (nothing new):
#   0002_post_views... already applied, skipping
#   0003_post_slug... already applied, skipping
# Final columns: ['id', 'title', 'views', 'slug']

# ✅ Expected output:
# Running migrate:
#   Applying 0002_post_views... OK
#   Applying 0003_post_slug... OK
# Running migrate again (nothing new):
#   0002_post_views... already applied, skipping
#   0003_post_slug... already applied, skipping
# Final columns: ['id', 'title', 'views', 'slug']

2 The Migration Graph & Dependencies

Migrations are not a flat list — they form a directed graph. Each file's dependencies point at the migrations that must run first. Django performs a topological sort of that graph to decide the real order, which is how a foreign key in one app can safely depend on a table created in another app.

# blog/migrations/0002_post_author.py
class Migration(migrations.Migration):
    dependencies = [
        ("blog", "0001_initial"),      # the Post table must exist first
        ("accounts", "0001_initial"),  # the Author table lives in another app
    ]
    operations = [
        migrations.AddField(
            model_name="post",
            name="author",
            field=models.ForeignKey(to="accounts.Author",
                                    on_delete=models.CASCADE),
        ),
    ]

# See what is applied vs pending:
python manage.py showmigrations
# blog
#  [X] 0001_initial
#  [ ] 0002_post_author      <-- not yet applied
# Django topologically sorts the migration graph so every dependency
# runs before the migration that needs it. Here is that ordering done
# with a tiny dependency resolver over the same idea.

# migration -> list of migrations it depends on
deps = {
    "blog.0001_initial":   [],
    "accounts.0001_initial": [],
    "blog.0002_post_author": ["blog.0001_initial", "accounts.0001_initial"],
    "blog.0003_post_tags":   ["blog.0002_post_author"],
}

def topo_order(deps):
    order = []
    done = set()
    def visit(node):
        if node in done:
            return
        for dep in deps[node]:
            visit(dep)
        done.add(node)
        order.append(node)
    for node in deps:
        visit(node)
    return order

for step in topo_order(deps):
    print("Apply", step)

# EXPECTED (each migration appears AFTER everything it depends on):
# Apply blog.0001_initial
# Apply accounts.0001_initial
# Apply blog.0002_post_author
# Apply blog.0003_post_tags

# ✅ Expected output:
# Apply blog.0001_initial
# Apply accounts.0001_initial
# Apply blog.0002_post_author
# Apply blog.0003_post_tags

3 Data Migrations, sqlmigrate, --fake & Squashing

Beyond schema changes, migrations can move data. A RunPython operation runs a function during migrate, giving you a historical version of the model to backfill or transform rows. You write a forwards function and, ideally, a reverse one so the migration can be rolled back.

# blog/migrations/0004_backfill_slug.py
from django.db import migrations
from django.utils.text import slugify

def fill_slugs(apps, schema_editor):
    Post = apps.get_model("blog", "Post")   # historical model
    for post in Post.objects.all():
        post.slug = slugify(post.title)
        post.save()

def clear_slugs(apps, schema_editor):
    Post = apps.get_model("blog", "Post")
    Post.objects.update(slug="")

class Migration(migrations.Migration):
    dependencies = [("blog", "0003_post_slug")]
    operations = [
        migrations.RunPython(fill_slugs, clear_slugs),
    ]
# A RunPython data migration transforms existing rows. The slugify-style
# backfill below is the exact kind of logic a forwards function performs.

def slugify(text):
    return "-".join(text.lower().split())

# Historical rows that already exist in the database
posts = [
    {"title": "Hello World", "slug": ""},
    {"title": "Django Data Migrations", "slug": ""},
    {"title": "Run Python In Migrate", "slug": ""},
]

def fill_slugs(rows):          # forwards
    for row in rows:
        row["slug"] = slugify(row["title"])

def clear_slugs(rows):         # reverse
    for row in rows:
        row["slug"] = ""

fill_slugs(posts)
for row in posts:
    print(row["title"], "->", row["slug"])

print("--- reversing ---")
clear_slugs(posts)
print("slugs after reverse:", [row["slug"] for row in posts])

# EXPECTED:
# Hello World -> hello-world
# Django Data Migrations -> django-data-migrations
# Run Python In Migrate -> run-python-in-migrate
# --- reversing ---
# slugs after reverse: ['', '', '']

# ✅ Expected output:
# Hello World -> hello-world
# Django Data Migrations -> django-data-migrations
# Run Python In Migrate -> run-python-in-migrate
# --- reversing ---
# slugs after reverse: ['', '', '']

🎯 Your Turn: Apply Only the Pending Migration

Fill in the blank so migrate applies only the migrations that are not already in the applied list.

# 🎯 YOUR TURN: replace ___ so already-applied migrations are skipped.

applied = ["0001_initial"]
pending = ["0001_initial", "0002_post_views", "0003_post_slug"]

for name in pending:
    if name ___ applied:        # skip the ones already recorded
        continue
    applied.append(name)
    print("Applying", name, "... OK")

print("Applied:", applied)

# EXPECTED OUTPUT:
# Applying 0002_post_views ... OK
# Applying 0003_post_slug ... OK
# Applied: ['0001_initial', '0002_post_views', '0003_post_slug']

Common Errors (And How to Fix Them)

❌ "You are trying to add a non-nullable field without a default"

You added a required field to a table that already has rows, so Django can't fill the gap.

✅ Fix: give the field a default=..., or make it null=True, then re-run makemigrations.

❌ "Conflicting migrations detected; multiple leaf nodes"

Two branches both created a 0002 migration, so the graph forked.

✅ Fix: run python manage.py makemigrations --merge to create a migration that depends on both leaves.

❌ "Table already exists" after adopting an existing database

The schema is already there, but Django still wants to run the initial migration.

✅ Fix: run python manage.py migrate --fake-initial to record it as applied without recreating tables.

📋 Quick Reference

CommandWhat It Does
makemigrationsWrite migration files describing model changes
migrateApply pending migrations to the database
showmigrationsList migrations and whether each is applied
sqlmigrate app 0004Print the SQL a migration would run
squashmigrationsCollapse many migrations into one file

🏆 Mini Challenge: Squash a Migration History

Squashing replays a list of operations and keeps the final column set, dropping intermediate churn. Write the optimizer that produces the squashed schema.

# 🏆 Mini Challenge: squash 0001..0004 into the final column list.
# Each operation either adds or removes a column; squashing replays
# them and reports only the columns that survive.

operations = [
    ("add", "id"),
    ("add", "title"),
    ("add", "subtitle"),
    ("remove", "subtitle"),   # later removed -> should not survive
    ("add", "slug"),
    ("add", "views"),
]

def squash(operations):
    columns = []
    for action, column in operations:
        if action == "add":
            columns.append(column)
        elif action == "remove" and column in columns:
            columns.remove(column)
    return columns

print("Squashed columns:", squash(operations))

# EXPECTED:
# Squashed columns: ['id', 'title', 'slug', 'views']

❓ Frequently Asked Questions

Lesson complete — you understand the whole migration pipeline!

You can now read a migration file, explain why makemigrations plans and migrate applies, follow the dependency graph, write a RunPython data migration, and reach for sqlmigrate, --fake, and squashing when you need them.

Practice quiz

What does makemigrations do?

  • Applies changes to the database
  • Writes migration files describing model changes
  • Deletes old migrations
  • Runs the development server

Answer: Writes migration files describing model changes. makemigrations plans changes by writing migration files; it never touches the DB.

What does migrate do?

  • Applies pending migration files to the database
  • Generates migration files
  • Prints the SQL only
  • Creates a new project

Answer: Applies pending migration files to the database. migrate executes the migration files against the database.

A migration file's dependencies attribute points at what?

  • The database password
  • Migrations that must run first
  • Unrelated apps to ignore
  • The settings module

Answer: Migrations that must run first. dependencies list the migrations that must be applied before this one.

How does Django decide the real order to apply migrations?

  • Alphabetically by file name
  • Randomly
  • By a topological sort of the dependency graph
  • By file size

Answer: By a topological sort of the dependency graph. Django topologically sorts the migration graph using dependencies.

Which operation runs Python to transform existing rows during migrate?

  • AddField
  • CreateModel
  • RunPython
  • AlterField

Answer: RunPython. migrations.RunPython runs a function to move or transform data.

Inside a data migration, how should you access a model?

  • Import it directly from models.py
  • apps.get_model('app', 'Model')
  • Model.objects directly
  • From settings

Answer: apps.get_model('app', 'Model'). Use apps.get_model() to get the historical model version.

What does migrate --fake do?

  • Deletes the migration
  • Runs the SQL twice
  • Reverses all migrations
  • Marks a migration applied without running its SQL

Answer: Marks a migration applied without running its SQL. --fake records a migration as applied without executing it.

Which command prints the SQL a migration would run without applying it?

  • sqlmigrate
  • showmigrations
  • squashmigrations
  • dbshell

Answer: sqlmigrate. sqlmigrate prints the SQL for a migration without running it.

Should migration files be committed to version control?

  • No, they are temporary
  • Only on production
  • Yes, always
  • Only the initial one

Answer: Yes, always. Migrations are source code recording schema history and must be committed.

How do you resolve conflicting leaf migrations from two branches?

  • Delete one migration
  • Reinstall Django
  • Run migrate --fake
  • Run makemigrations --merge

Answer: Run makemigrations --merge. makemigrations --merge adds a migration depending on both leaves.

Continue this course

Frequently asked questions

What is the difference between makemigrations and migrate?

makemigrations reads the current state of your models and writes new migration files that describe the changes you made — it does not touch the database. migrate then takes those migration files and actually applies them to the database, creating or altering tables. In short, makemigrations plans the change as a Python file and migrate executes it against the database.

Should I commit migration files to version control?

Yes, always. Migration files are part of your project's source code and they record the exact history of your schema. Committing them means every developer and every server applies the same ordered set of changes, so databases stay in sync. Deleting or regenerating them on a shared project is a common cause of conflicts and broken deployments.

What does a data migration with RunPython do?

A data migration moves or transforms existing rows rather than changing the table structure. You add a migrations.RunPython operation that calls a Python function receiving the historical model and a database alias, letting you backfill columns, split fields, or populate new tables. RunPython takes a forwards function and an optional reverse function so the change can be undone.

When should I use the --fake flag?

Use migrate --fake to mark a migration as applied without running its SQL. This is for situations where the database already matches the migration — for example after manually creating a table, or when adopting Django on an existing schema. It is a sharp tool: faking a migration whose changes are not actually present will leave your database and migration history out of sync.