Django migrations are Python files that describe how to move a database schema from one state to the next, generated by comparing your models with the state that all previous migrations add up to. They work well as long as you understand three things: what a migration file contains, that Django computes changes from migration history rather than from the live database, and which operations are safe to run against a production table. This guide covers all three, plus squashing, CI checks and the mistakes that show up again and again.
What a migration file contains#
Running python manage.py makemigrations writes a module into the app's migrations package. It holds one Migration class with two lists: dependencies, the migrations that must be applied first, and operations, the changes to make. Here is a real one, the third file of the PetClinic sample used across this site:
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("petclinic", "0002_vets_specialties"),
]
operations = [
migrations.CreateModel(
name="Visit",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("pet", models.ForeignKey(
db_column="pet_id",
on_delete=django.db.models.deletion.DO_NOTHING,
to="petclinic.pet",
)),
("visit_date", models.DateField(null=True)),
("description", models.CharField(max_length=255, null=True)),
],
options={"db_table": "visits"},
),
]
Operations are declarative. CreateModel, AddField, AlterField, RenameField, AddIndex, AddConstraint and their siblings each know how to produce SQL for every supported backend and how to update the in-memory model state. That second duty is why the file must stay in the repository: Django rebuilds the model state from all migration files whenever it needs to compare it with your current models.
How Django decides what to generate#
makemigrations does not look at the database. It loads every migration of every app, replays the operations to reconstruct the models as the migrations define them, and diffs that against the models in your code. The database only enters the picture when you run migrate, which consults the django_migrations table to see which migration names have been applied and runs the rest in dependency order.
Three commands make this visible. showmigrations lists migrations with an [X] for applied ones. migrate --plan prints what would run, in order, without running it. sqlmigrate petclinic 0003 prints the SQL a migration will execute on the configured backend, which is the single most useful command when reviewing a migration for a large table.
python manage.py showmigrations petclinic
python manage.py migrate --plan
python manage.py sqlmigrate petclinic 0003_visits
Dependencies and ordering across apps#
Migrations form a graph, not a list. Within an app the generated dependency on the previous migration keeps them linear; across apps, a foreign key to another app's model adds a dependency on that app's migration, and a foreign key to the user model adds migrations.swappable_dependency(settings.AUTH_USER_MODEL). Django resolves the whole graph and applies migrations in a valid order, so two apps can each be developed independently as long as their dependencies are declared.
The graph is also where branch conflicts appear. When two developers each add 0004_something to the same app, the app has two leaf nodes and migrate refuses to run. makemigrations --merge creates an empty migration that depends on both leaves, which is fine when the two changes are independent and wrong when they touch the same field; in the second case, delete one and regenerate it on top of the other.
Data migrations: RunPython and RunSQL#
Schema changes are generated; data changes are written by hand with RunPython or RunSQL. Inside a RunPython function you must fetch models through apps.get_model, which returns the historical version of the model as it was at that point in the graph, without your custom methods and without fields added later:
from django.db import migrations
def fill_type_names(apps, schema_editor):
PetType = apps.get_model("petclinic", "PetType")
for name in ("cat", "dog", "hamster"):
PetType.objects.get_or_create(name=name)
class Migration(migrations.Migration):
dependencies = [("petclinic", "0001_owners_types_pets")]
operations = [
migrations.RunPython(fill_type_names, migrations.RunPython.noop),
]
The second argument is the reverse function; RunPython.noop makes the migration reversible without undoing the data, which is usually what you want for seed data. Keep data migrations in their own files, separate from schema changes: they run at different speeds, fail for different reasons, and on PostgreSQL a mixed migration can hit "pending trigger events" errors when a schema change follows a data change in the same transaction.
Migrations are atomic by default on backends that support transactional DDL (PostgreSQL and SQLite). Set atomic = False on the class for long backfills that should commit in batches, and for operations that cannot run inside a transaction at all, such as AddIndexConcurrently from django.contrib.postgres.operations.
Non-nullable fields and big tables#
Adding a NOT NULL field without a default makes makemigrations stop and ask for a one-off default, because existing rows need a value. Answering the prompt works, but the resulting migration rewrites every row in one statement, which on a large table means a long lock. The safer sequence is the expand and contract pattern: add the field as nullable, backfill in batches in a separate non-atomic data migration, then alter the field to NOT NULL. Zero-downtime migrations with expand and contract walks through it in detail.
Squashing#
An app with two hundred migrations still works, but every test run replays all of them and every makemigrations loads them. squashmigrations petclinic 0001 0040 produces a single migration whose replaces attribute lists the originals; databases that already applied the originals treat the squash as done, fresh databases run only the squash. Two cautions: RunPython operations are kept unless marked elidable=True, and the original files must stay until every environment has migrated past them, after which you delete them and remove the replaces list.
Migrations in CI#
Two checks belong in every pipeline. makemigrations --check --dry-run exits with a non-zero status when a model change has no migration, catching the developer who edited a model and forgot to generate. migrate --check exits non-zero when migrations exist that have not been applied to the target database, which is useful as a deployment gate. Both are cheap.
python manage.py makemigrations --check --dry-run
python manage.py migrate --check
Name migrations when you create them: makemigrations petclinic -n add_visit_notes. Django tries to derive a name from the operations, but a fallback such as 0007_auto_20260623_1412 tells a reviewer nothing, and the name lives in django_migrations for the life of the project.
Traps that catch teams#
- Editing an applied migration. Django does not checksum files, so nothing complains, but environments that already ran the old version now differ from those that run the new one. Add a new migration instead.
- A missing
__init__.pyin the migrations directory. The package is not found, the app is treated as having no migrations, and its tables are created directly from the models with no history. Keep the file, even empty. - Database-specific SQL in
RunSQL. It works on PostgreSQL in production and fails on the SQLite test database. Branch onschema_editor.connection.vendor, or run tests on the production engine. - Deploying several instances that all run
migrate. Run migrations once, from one job, before the new code starts. - Renames seen as drop and add.
makemigrationsasks whether a field was renamed; answer carefully, because a wrong answer drops the column.
More of these, across tools, are collected in database migration anti-patterns.
Where SchemaDrawer fits#
Because Django rebuilds the schema from migration files, those files are the truest description of the database you have, and the truest input for a diagram. SchemaDrawer takes the app directories with their migrations packages, runs migrate against a throw-away database of the engine and version you choose, and returns an ER diagram of the tables, columns, primary keys and foreign keys that result. The service runs a current Django release, which refuses PostgreSQL older than 15, MySQL 8.0 and MariaDB 10.6, so pick a version the framework itself supports; the Django page lists the upload layout and the PostgreSQL page the available versions.
Key takeaways#
- A migration is dependencies plus operations; Django diffs models against replayed migrations, not against the database.
- Use
sqlmigratebefore running anything on a large table. - Keep data migrations separate, use historical models, make them non-atomic when they are long.
- Squash when history slows you down, and keep the originals until every environment is past them.
- Put
makemigrations --checkandmigrate --checkin CI.