Skip to main content

🧬 Database Migrations

Your models describe the schema you want; migrations are how Django turns that description into real database tables and evolves them safely over time. Think of migrations as version control for your database — a reproducible history of every structural change your project has ever made.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what migrations are and why they beat hand-editing the database
  • Run the two-step workflowmakemigrations then migrate — with confidence
  • Read the anatomy of a migration file and recognize the common operations Django generates
  • Write a data migration with RunPython using the historical model
  • Resolve migration conflicts and apply migrations safely in production

Estimated Time: 45–60 minutes  •  Difficulty: Intermediate

Hands-on: Evolve a small library schema across three migrations, including one data migration.

In This Lesson

What Migrations Really Are

A migration is a Python file that records a change to your database schema — creating a table, adding a column, renaming a field, adding an index. Django reads your models, compares them to the migrations already recorded, and generates new migration files describing the difference. Applying those files runs the equivalent SQL against your database.

Migrations exist to solve a real problem: your models change constantly during development, but you can't just drop and recreate the database every time — that would throw away all your data and there'd be no shared record of how the schema got to its current shape. Migrations give you a reproducible, ordered history that every developer, every server, and your CI pipeline can replay to reach an identical schema.

💡 A useful analogy: Migrations are Git for your database. Each migration is a commit: small, ordered, and dependent on the ones before it. You can move forward (apply) or backward (unapply), and because the history is committed to your repo, a teammate who clones the project can rebuild the exact same schema with one command.
flowchart LR A[models.py
what you want] -->|makemigrations| B[Migration files
0001, 0002, ...] B -->|migrate| C[(Database schema
what actually exists)] C -.->|migrate app 0001| B

📖 Key Terms

Migration: a file in an app's migrations/ folder describing one or more schema operations.

makemigrations: compares models to existing migrations and writes new migration files.

migrate: applies unapplied migrations to the database, and records which ones ran in the django_migrations table.

The Two-Step Workflow

Almost everything you do with migrations is a rhythm of two commands. Change a model, then:

sequenceDiagram participant Dev as Developer participant Django participant DB as Database Dev->>Django: edit models.py Dev->>Django: manage.py makemigrations Django-->>Dev: writes 000X_description.py Dev->>Django: manage.py migrate Django->>DB: run the schema SQL DB-->>Django: records migration as applied Django-->>Dev: OK

Step 1 — create the migration

After editing your models, ask Django to write the migration file. Scope it to one app to keep output tidy:

python manage.py makemigrations blog

Output

Migrations for 'blog':
  blog/migrations/0002_post_category.py
    + Add field category to post

Step 2 — apply the migration

migrate runs every unapplied migration, in dependency order, across all apps:

python manage.py migrate

You can also target a single app, or migrate to a specific migration — which is how you roll changes back:

# Apply all pending migrations for one app
python manage.py migrate blog

# Roll the blog app BACK to migration 0002 (unapplies 0003, 0004, ...)
python manage.py migrate blog 0002

# Unapply every blog migration
python manage.py migrate blog zero

⚠️ makemigrations touches files, not the database

A common beginner mistake is running only makemigrations and wondering why the database didn't change. makemigrations only writes a file. Nothing hits the database until you run migrate. If you ever see "You have N unapplied migration(s)" on startup, that's Django reminding you to run migrate.

Anatomy of a Migration File

Migration files live in <app>/migrations/ and are named NNNN_description.py, where NNNN is a zero-padded sequence number. You almost never write them by hand — but you must be able to read them, because reviewing a migration before applying it is part of the job.

# blog/migrations/0001_initial.py
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):
    initial = True

    dependencies = []          # nothing must run before this

    operations = [
        migrations.CreateModel(
            name="Author",
            fields=[
                ("id", models.BigAutoField(auto_created=True, primary_key=True,
                                           serialize=False, verbose_name="ID")),
                ("name", models.CharField(max_length=100)),
                ("email", models.EmailField(max_length=254)),
            ],
        ),
        migrations.CreateModel(
            name="Post",
            fields=[
                ("id", models.BigAutoField(auto_created=True, primary_key=True,
                                           serialize=False, verbose_name="ID")),
                ("title", models.CharField(max_length=200)),
                ("content", models.TextField()),
                ("published_at", models.DateTimeField(default=django.utils.timezone.now)),
                ("author", models.ForeignKey(
                    on_delete=django.db.models.deletion.CASCADE, to="blog.author")),
            ],
        ),
    ]

Four parts matter:

  • Migration class — every migration subclasses migrations.Migration.
  • initialTrue only for an app's very first migration.
  • dependencies — the migrations that must run first, written as (app_label, migration_name) tuples. This is what enforces ordering across apps.
  • operations — the ordered list of schema changes to perform.

💡 The default primary key is BigAutoField

Older tutorials show models.AutoField for the auto-generated id. Since Django 3.2 the project default is BigAutoField (a 64-bit integer), set by DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" in settings.py. New migrations you generate will show BigAutoField.

Common Operations

Each item in operations is a schema change. Django generates these for you, but knowing the vocabulary helps you read and review migrations quickly.

OperationWhat it does
CreateModelCreate a new table
DeleteModelDrop a table
AddFieldAdd a column
RemoveFieldDrop a column
AlterFieldChange a column's definition (e.g. max_length)
RenameFieldRename a column, preserving its data
RenameModelRename a table
AddIndex / AddConstraintAdd an index or a database constraint
RunPythonRun arbitrary Python — the heart of a data migration
RunSQLRun raw SQL for changes the ORM can't express

Here are two you'll see constantly. Adding a field:

migrations.AddField(
    model_name="post",
    name="category",
    field=models.ForeignKey(
        null=True,
        on_delete=django.db.models.deletion.SET_NULL,
        to="blog.category",
    ),
)

Altering a field (here, widening a CharField):

migrations.AlterField(
    model_name="post",
    name="title",
    field=models.CharField(max_length=300),   # was 200
)

⚠️ Adding a non-nullable field to a populated table

If you add a required field (null=False) to a table that already has rows, Django can't guess what value the existing rows should get. It will pause and ask you to provide a one-off default, or you can add default=… (or null=True) to the field first. Plan for this before running makemigrations.

Management Commands

Beyond makemigrations and migrate, a handful of commands make migrations easy to inspect and manage.

Name your migrations

A descriptive name makes the history readable months later:

python manage.py makemigrations blog --name add_published_flag

See what's applied

python manage.py showmigrations blog

Output ([X] = applied, [ ] = pending)

blog
 [X] 0001_initial
 [X] 0002_post_category
 [ ] 0003_add_published_flag

Preview the SQL

sqlmigrate prints the SQL a migration will run — invaluable for reviewing risky changes before they touch production:

python manage.py sqlmigrate blog 0001_initial

Check without writing files

Great in CI: fail the build if someone changed a model but forgot to generate the migration:

python manage.py makemigrations --check --dry-run

Squash a long history

After many migrations pile up, collapse a range into one for faster fresh installs:

python manage.py squashmigrations blog 0001 0012

✅ Add --check to your pipeline

makemigrations --check --dry-run returns a non-zero exit code when models and migrations are out of sync. Wiring it into CI stops the classic "works on my machine, missing migration in prod" bug before it ever merges.

Data Migrations

Sometimes a schema change isn't enough — you also need to transform existing data. Classic cases: back-filling a new slug column from an existing title, seeding default rows, or splitting one field into two. That's a data migration, built with the RunPython operation.

Step 1 — create an empty migration

python manage.py makemigrations blog --empty --name populate_slugs

Step 2 — add a RunPython function

from django.db import migrations
from django.utils.text import slugify


def populate_slugs(apps, schema_editor):
    # Use the HISTORICAL model, not a direct import
    Post = apps.get_model("blog", "Post")
    for post in Post.objects.all():
        post.slug = slugify(post.title)
        post.save(update_fields=["slug"])


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(populate_slugs, reverse_code=clear_slugs),
    ]

⚠️ Always use apps.get_model() — never import the model directly

Inside a migration, apps.get_model("blog", "Post") returns the historical version of the model — its shape at this point in the migration history. If you from blog.models import Post instead, you get today's model, which may have fields that don't exist yet at this step. That mismatch causes migrations to fail on a fresh database.

💡 Make it reversible

Passing reverse_code lets migrate blog 0003 undo the data change cleanly. If a data migration truly can't be reversed, use migrations.RunPython.noop as the reverse to say so explicitly rather than leaving it blank.

Conflicts & Production

Resolving a conflict

When two teammates branch off the same migration and each adds a new one, you get two migrations with the same number (say two 0004_ files). After merging their code, let Django reconcile the branch:

python manage.py makemigrations --merge

Django writes a small 0005_merge_… migration whose only job is to declare both 0004 migrations as dependencies, re-uniting the history into a single line.

Rolling out safely

Production databases hold real, irreplaceable data, so migrations there need a plan:

  • Back up first. Always snapshot before applying. pg_dump for PostgreSQL, a file copy for SQLite.
  • Test on a copy of production data in staging, not just on your near-empty dev database.
  • Prefer additive, backward-compatible steps. To rename a column with zero downtime: add the new column, deploy code that writes both, back-fill, switch reads to the new column, then drop the old one in a later release.
  • Watch for table locks. Adding a nullable column is usually fast; back-filling millions of rows in one transaction is not — batch it.
  • Automate in CI/CD so every deploy runs migrate --no-input the same way.
# Back up before migrating (PostgreSQL)
pg_dump -U app -d production > pre_migration_backup.sql

# In the deploy script
python manage.py migrate --no-input

✅ The golden rule

Commit migration files to version control alongside the model change that generated them. A model change without its migration — or a migration without its model change — will break the next person who pulls your branch.

Hands-on Exercise

🏋️ Evolve a library schema

Objective: Practice the full migration lifecycle — create, alter, and back-fill data — on a small library app.

Steps:

  1. Create a Book model with title and author (a CharField). Run makemigrations books and migrate.
  2. Add a slug = models.SlugField(blank=True) field to Book. Generate and apply the schema migration.
  3. Create an empty migration named populate_book_slugs and write a reversible RunPython that fills each book's slug from its title.
  4. Run showmigrations books and confirm all three are marked [X].
  5. Bonus: roll the app back to the first migration, then forward again.
💡 Hint

Use --empty --name populate_book_slugs for step 3, and remember apps.get_model("books", "Book") for the historical model. To roll back in the bonus: python manage.py migrate books 0001, then python manage.py migrate books to go forward again.

✅ Sample solution (step 3 migration)
from django.db import migrations
from django.utils.text import slugify


def fill_slugs(apps, schema_editor):
    Book = apps.get_model("books", "Book")
    for book in Book.objects.all():
        book.slug = slugify(book.title)
        book.save(update_fields=["slug"])


class Migration(migrations.Migration):
    dependencies = [
        ("books", "0002_book_slug"),
    ]

    operations = [
        migrations.RunPython(fill_slugs, reverse_code=migrations.RunPython.noop),
    ]

🎯 Quick Quiz

Question 1: You edited a model and ran only makemigrations. Why is the database still unchanged?

Question 2: Inside a data migration, why should you use apps.get_model("blog", "Post") instead of importing Post directly?

Question 3: Two teammates each created a 0004_ migration on separate branches. After merging, which command reconciles the split history?

Best Practices

✅ Do

  • Commit migration files together with the model changes that generated them.
  • Give migrations descriptive names with --name.
  • Review the SQL with sqlmigrate before applying anything risky.
  • Make data migrations reversible with reverse_code (or RunPython.noop).
  • Back up production and rehearse on staging before migrating live data.
  • Add makemigrations --check --dry-run to CI.

⚠️ Don't

  • Don't hand-edit the database to match a model — always go through a migration.
  • Don't import models directly inside a data migration; use apps.get_model().
  • Don't edit a migration that has already been applied on other machines — write a new one.
  • Don't back-fill millions of rows in a single unbatched transaction on a live table.

Summary & Quiz

🎉 Key Takeaways

  • Migrations are version control for your schema: an ordered, reproducible history of changes.
  • The core rhythm is two commands: makemigrations writes files, migrate applies them.
  • A migration file is a Migration class with dependencies and an operations list.
  • Data migrations transform data with RunPython — always via the historical apps.get_model().
  • In teams and production: merge conflicts with --merge, back up first, and prefer backward-compatible steps.

📚 Further Reading

🚀 What's Next?

Your schema now exists and can evolve safely. Next, in QuerySets and Model Managers, you'll learn to read and write that data with Django's ORM — filtering, ordering, aggregating, and optimizing queries in clean, Pythonic code.

🎉 Well done!

You can now evolve a database schema with confidence — and undo it when you need to.