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:

  1. Add new model PostBody.
  2. Data migration: copy Post.body → PostBody.body.
  3. Remove body from 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:

  1. Add nullable column in one deploy.
  2. Backfill in next deploy / async.
  3. 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 User directly in migration (uses CURRENT model, not historical) — use apps.get_model.
  • RunPython referencing services / imports — model methods don’t exist historically.
  • Adding NOT NULL without default → migration prompts, then fails.
  • Forgetting --fake-initial after 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 .