Django Migrations Explained: From makemigrations to Production
What Django migration files contain, how dependencies and RunPython work, when to squash, how to check migrations in CI, and the traps that catch teams.
The migrations directories of your Django apps, as makemigrations writes them. SchemaDrawer runs Django 6.1 on Python 3.13 against a throw-away database of the engine you choose and returns an SVG of the schema it produces.
Upload your app directories. Every directory named migrations/ that holds Python files makes its parent an app: the parent's name becomes the app label, so it has to be a valid identifier and unique in the upload. Only the migration modules are used; models.py, apps.py and the rest can stay home.
The generated settings list only the uploaded apps in INSTALLED_APPS, with DEFAULT_AUTO_FIELD set to AutoField and USE_TZ on.
The sample below is the PetClinic schema written for Django, one of the sets SchemaDrawer's own tests draw on every engine:
myproject/
└── petclinic/
├── migrations/
│ ├── 0001_owners_types_pets.py
│ ├── 0002_vets_specialties.py
│ ├── 0003_visits.py
│ └── __init__.py
└── __init__.py
Django migration modules (the Migration class with dependencies and operations).
python -m django migrate --no-input. The database is a fresh PostgreSQL, MySQL, MariaDB, SQL Server, Oracle or SQLite container in the version you ask for; nothing of yours is touched.
PostgreSQL 15, 16, 17, 18 MySQL 8.4, 9 MariaDB 11.4, 11.8, 12 SQL Server 2017, 2019, 2022, 2025 Oracle 21, 23 SQLite
Django 6 refuses PostgreSQL 10 to 14, MySQL 8.0 and MariaDB 10.6, so those versions are not listed.
migrationType=DJANGOOwners, pets, types, vets, specialties and visits: the same seven tables every sample set on this site creates. These are the exact files the tests upload.
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Owner",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("first_name", models.CharField(max_length=50)),
("last_name", models.CharField(max_length=50)),
("address", models.CharField(max_length=255, null=True)),
("city", models.CharField(max_length=80, null=True)),
("telephone", models.CharField(max_length=20, null=True)),
],
options={
"db_table": "owners",
},
),
migrations.CreateModel(
name="Type",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=80)),
],
options={
"db_table": "types",
},
),
migrations.CreateModel(
name="Pet",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=50)),
("birth_date", models.DateField(null=True)),
(
"type",
models.ForeignKey(
db_column="type_id",
on_delete=django.db.models.deletion.DO_NOTHING,
to="petclinic.type",
),
),
(
"owner",
models.ForeignKey(
db_column="owner_id",
on_delete=django.db.models.deletion.DO_NOTHING,
to="petclinic.owner",
),
),
],
options={
"db_table": "pets",
},
),
]
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("petclinic", "0001_owners_types_pets"),
]
operations = [
migrations.CreateModel(
name="Vet",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("first_name", models.CharField(max_length=50)),
("last_name", models.CharField(max_length=50)),
],
options={
"db_table": "vets",
},
),
migrations.CreateModel(
name="Specialty",
fields=[
("id", models.IntegerField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=80)),
],
options={
"db_table": "specialties",
},
),
migrations.CreateModel(
name="VetSpecialty",
fields=[
("pk", models.CompositePrimaryKey("vet", "specialty")),
(
"vet",
models.ForeignKey(
db_column="vet_id",
on_delete=django.db.models.deletion.DO_NOTHING,
to="petclinic.vet",
),
),
(
"specialty",
models.ForeignKey(
db_column="specialty_id",
on_delete=django.db.models.deletion.DO_NOTHING,
to="petclinic.specialty",
),
),
],
options={
"db_table": "vet_specialties",
},
),
]
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",
},
),
]
Every variant of the sample produces these seven tables. This diagram was drawn from the Flyway variant on PostgreSQL 18; on MySQL the types read INT and VARCHAR, on Oracle the names are upper case.
One multipart request queues the drawing; poll the job until it succeeds and download the SVG. Or let the GitHub Action do it and open a pull request with the diagram.
curl -X POST https://schemadrawer.com/api/v1/drawing \
-H "Request-Id: $(uuidgen)" \
-F "userId=5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f" \
-F "migrationType=DJANGO" \
-F "databaseType=POSTGRESQL" \
-F "version=18" \
-F "files=@myproject/petclinic/__init__.py;filename=petclinic/__init__.py" \
-F "files=@myproject/petclinic/migrations/0001_owners_types_pets.py;filename=petclinic/migrations/0001_owners_types_pets.py" \
-F "files=@myproject/petclinic/migrations/0002_vets_specialties.py;filename=petclinic/migrations/0002_vets_specialties.py" \
-F "files=@myproject/petclinic/migrations/0003_visits.py;filename=petclinic/migrations/0003_visits.py" \
-F "files=@myproject/petclinic/migrations/__init__.py;filename=petclinic/migrations/__init__.py"
# {"jobId":"0b6e2f4c-8d1a-4c3e-9f7a-2a5b6c7d8e9f"}
Then poll GET https://schemadrawer.com/api/v1/drawing/{jobId} until status is SUCCESS and fetch diagramUrl. Details in the getting started guide and the API reference.
- uses: schemadrawercom/action@v1
with:
sql-directory: myproject
output-path: docs/schema-diagram.svg
migration-type: DJANGO
database-type: POSTGRESQL
database-version: '18'
github-token: ${{ secrets.GITHUB_TOKEN }}
The action uploads the directory, waits for the diagram and opens a pull request with the SVG. See the action reference.
django.contrib app is installed, so a migration that depends on auth.User, content types or another contrib app cannot run.django_migrations table is left out of the diagram.What Django migration files contain, how dependencies and RunPython work, when to squash, how to check migrations in CI, and the traps that catch teams.
Editing applied migrations, environment-specific scripts, ORM auto-DDL in production, untested rollbacks: ten habits that break deployments, with a fix for each.
How Diesel manages a schema in Rust: the CLI, timestamped folders with up.sql and down.sql, the versions table, the generated schema.rs and embedded migrations.
Upload the migration directory, get an SVG back. Free while we build it.