🧬 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 workflow —
makemigrationsthenmigrate— with confidence - Read the anatomy of a migration file and recognize the common operations Django generates
- Write a data migration with
RunPythonusing 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.
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:
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:
Migrationclass — every migration subclassesmigrations.Migration.initial—Trueonly 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.
| Operation | What it does |
|---|---|
CreateModel | Create a new table |
DeleteModel | Drop a table |
AddField | Add a column |
RemoveField | Drop a column |
AlterField | Change a column's definition (e.g. max_length) |
RenameField | Rename a column, preserving its data |
RenameModel | Rename a table |
AddIndex / AddConstraint | Add an index or a database constraint |
RunPython | Run arbitrary Python — the heart of a data migration |
RunSQL | Run 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_dumpfor 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-inputthe 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:
- Create a
Bookmodel withtitleandauthor(aCharField). Runmakemigrations booksandmigrate. - Add a
slug = models.SlugField(blank=True)field toBook. Generate and apply the schema migration. - Create an empty migration named
populate_book_slugsand write a reversibleRunPythonthat fills each book's slug from its title. - Run
showmigrations booksand confirm all three are marked[X]. - 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
sqlmigratebefore applying anything risky. - Make data migrations reversible with
reverse_code(orRunPython.noop). - Back up production and rehearse on staging before migrating live data.
- Add
makemigrations --check --dry-runto 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:
makemigrationswrites files,migrateapplies them. - A migration file is a
Migrationclass withdependenciesand anoperationslist. - Data migrations transform data with
RunPython— always via the historicalapps.get_model(). - In teams and production: merge conflicts with
--merge, back up first, and prefer backward-compatible steps.
📚 Further Reading
- Django Docs — Migrations
- Django Docs — Migration operations reference
- Django Docs — How to write migrations
- Django Docs — Data migrations
🚀 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.