Django migrations cheatsheet.
Commands
uv run python manage.py makemigrations
uv run python manage.py makemigrations blog
uv run python manage.py makemigrations --name add_published
uv run python manage.py migrate
uv run python manage.py migrate blog
uv run python manage.py migrate blog 0003 # to specific
uv run python manage.py migrate blog zero # rollback all
uv run python manage.py showmigrations
uv run python manage.py sqlmigrate blog 0003 # show SQL
uv run python manage.py squashmigrations blog 0001 0010
Empty migration (for data migration)
uv run python manage.py makemigrations blog --empty --name backfill_slugs
# blog/migrations/0005_backfill_slugs.py
from django.db import migrations
from django.utils.text import slugify
def forward(apps, schema_editor):
Post = apps.get_model("blog", "Post")
for p in Post.objects.filter(slug=""):
p.slug = slugify(p.title)
p.save()
def backward(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("blog", "0004_post_slug")]
operations = [migrations.RunPython(forward, backward)]
Always use apps.get_model — historical version of the model.
Adding a NOT NULL field
# Step 1: add nullable
class Post(...):
new_field = models.CharField(max_length=100, null=True)
# makemigrations + migrate
# Step 2: data migration to backfill
def forward(apps, schema_editor):
Post = apps.get_model("blog", "Post")
Post.objects.update(new_field="default")
# Step 3: make NOT NULL
class Post(...):
new_field = models.CharField(max_length=100)
Three migrations. Splits risky in production.
RunSQL
operations = [
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY post_title_idx ON blog_post (title)",
reverse_sql="DROP INDEX post_title_idx",
),
]
For Postgres index creation: CONCURRENTLY doesn’t lock.
atomic = False
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.RunSQL("CREATE INDEX CONCURRENTLY ..."),
]
For Postgres CONCURRENTLY migrations.
Squashing migrations
uv run python manage.py squashmigrations blog 0001 0020
Generates a single migration replacing 0001-0020. Useful when you have hundreds of migrations.
Keep originals until everyone’s deployed; delete after.
Faking migrations
uv run python manage.py migrate --fake-initial
uv run python manage.py migrate blog 0005 --fake
Marks as run without executing. Use carefully — typically when migrating an existing DB.
Splitting a model
# Original
class Post(models.Model):
title = models.CharField(...)
body = models.TextField()
# Split: move body to PostBody
class Post(models.Model):
title = models.CharField(...)
class PostBody(models.Model):
post = models.OneToOneField(Post, on_delete=models.CASCADE)
body = models.TextField()
Migrations:
- Add new model
PostBody. - Data migration: copy Post.body → PostBody.body.
- Remove
bodyfrom Post.
Renaming a field
# Django can auto-detect if name change is similar:
old_name = models.CharField(...)
# →
new_name = models.CharField(...)
uv run python manage.py makemigrations
# Prompts: "Did you rename old_name → new_name? [y/N]"
Confirm only if it’s truly a rename.
Custom dependencies
class Migration(migrations.Migration):
dependencies = [
("blog", "0003_xxx"),
("auth", "0012_alter_user_first_name_max_length"),
]
Cross-app data migration
def forward(apps, schema_editor):
User = apps.get_model("auth", "User")
Post = apps.get_model("blog", "Post")
...
Constraints / indexes
class Post(models.Model):
...
class Meta:
constraints = [
models.UniqueConstraint(fields=["slug", "author"], name="unique_slug_author"),
models.CheckConstraint(check=Q(views__gte=0), name="non_negative_views"),
]
indexes = [
models.Index(fields=["-created_at", "author"]),
models.Index(fields=["slug"]),
]
DB-specific features (Postgres)
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.fields import ArrayField, JSONField
class Post(models.Model):
tags = ArrayField(models.CharField(max_length=50))
meta = models.JSONField()
class Meta:
indexes = [GinIndex(fields=["tags"])]
Zero-downtime deploys
Rules of thumb:
- Add nullable column in one deploy.
- Backfill in next deploy / async.
- Make NOT NULL in third deploy (after code uses it).
Never:
- Drop columns referenced by old code.
- Rename columns the old code reads.
- Add NOT NULL without default in one shot on huge tables.
DB locking concerns
In Postgres, schema changes lock tables. For big tables:
- Add columns with default
NULL(cheap). - Index with
CONCURRENTLY(no lock). - Rename columns: avoid; instead add new + copy + drop later.
Inspect production diff
uv run python manage.py migrate --plan
uv run python manage.py sqlmigrate blog 0010
Run the SQL through DBA / staging before prod.
Common mistakes
- Editing applied migrations — breaks others’ history.
- Using
Userdirectly in migration (uses CURRENT model, not historical) — useapps.get_model. RunPythonreferencing services / imports — model methods don’t exist historically.- Adding NOT NULL without default → migration prompts, then fails.
- Forgetting
--fake-initialafter restoring a DB → tries to re-create existing tables.
Read this next
If you want my migration recipes, they’re at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .