DEV Community

Cover image for Django Migrations Explained: A Complete Production Guide
Alanso Mathew
Alanso Mathew

Posted on

Django Migrations Explained: A Complete Production Guide

From makemigrations to zero-downtime deployments — everything a backend engineer needs to understand, write, and ship Django migrations safely at scale.


Orginally Posted in :https://alansomathewdev.blogspot.com/2026/04/django-migrations-explained-complete.html

Table of Contents

  1. Introduction
  2. Why This Matters in Production Systems
  3. Core Concepts
  4. Architecture Design
  5. Step-by-Step Implementation
  6. Code Examples
  7. Performance Optimization
  8. Security Best Practices
  9. Common Developer Mistakes
  10. Real Production Use Cases
  11. Conclusion

Introduction

The first time most Django developers run python manage.py migrate, they're amazed. A handful of numbered Python files appear in a migrations/ folder, and suddenly their database has tables, columns, and constraints perfectly matching their model definitions — without writing a single line of SQL.

The second time most Django developers are in a war room at midnight — a failed migration locked a 50-million-row table, production is down, and nobody has a rollback plan.

Those two experiences define the full range of what Django migrations are. Used well, they are a version control system for your database schema — one of the most powerful features Django offers. Used carelessly, they are a deployment risk that can corrupt data, cause multi-hour outages, and create inconsistencies between environments that take weeks to untangle.

This guide covers the complete picture. We'll trace a migration from model change to database execution, walk through every internal component Django uses, build production-grade data migrations, tackle the hard problem of zero-downtime deployments, and equip you with the patterns senior engineers use to ship schema changes safely on live systems with millions of rows.

Whether you're running your first migration or trying to safely rename a column on a table with 100 million rows, this article has what you need.


Why This Matters in Production Systems

Database migrations are the most common cause of application downtime during a deployment. This isn't an opinion — it's a structural reality of how relational databases work.

Here's the scenario that brings teams to crisis:

You're deploying a new feature. It adds a phone_number field to the User model. The migration runs. In a rolling deployment (Kubernetes, ECS, Heroku, Render, Fly.io), there's a 60-second window where the new container has applied the migration and the old container is still serving traffic. This works fine. Then two weeks later, you remove a deprecated legacy_id field. The migration drops the column. The old container — still serving 30% of traffic — immediately throws 500 errors because it's querying a column that no longer exists.

This is not a Django bug. It is a fundamental constraint of rolling deployments. When you run a destructive migration, the database schema changes, and any application code running against the old schema immediately fails. The solution is not infrastructure configuration. It's how you write migrations.

Beyond the deployment problem:

  • Data migrations gone wrong can silently corrupt millions of rows with no rollback
  • Missing rollback functions on data migrations block emergency recovery
  • Long-running migrations on large tables lock the table and starve all reads and writes
  • Conflicting migrations from parallel development branches create irreconcilable states
  • Unapplied migrations in production cause mysterious bugs that don't reproduce in development

Understanding migrations deeply is how you avoid all of these.


Core Concepts

What Is a Migration?

Migrations are Django's way of propagating changes you make to your models (adding a field, deleting a model, etc.) into your database schema. They're designed to be mostly automatic, but you'll need to know when to make migrations, when to run them, and the common problems you might run into.

A migration file is a Python module containing one Migration class with two key attributes:

  • dependencies — a list of migrations this one must run after
  • operations — a list of Operation objects describing the schema changes
# apps/users/migrations/0003_add_phone_number.py
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ("users", "0002_add_avatar"),   # ← must run after 0002
    ]

    operations = [
        migrations.AddField(
            model_name="user",
            name="phone_number",
            field=models.CharField(max_length=20, null=True, blank=True),
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

The django_migrations Table

Django tracks which migrations have been applied using a table called django_migrations in your database. Every time you run migrate, Django checks this table, compares it to the migration files on disk, and applies only the unapplied ones.

SELECT app, name, applied FROM django_migrations ORDER BY applied DESC LIMIT 5;

 app        | name                             | applied
------------+----------------------------------+------------------------
 users      | 0003_add_phone_number            | 2025-03-15 14:22:11
 orders     | 0012_add_external_reference      | 2025-03-15 14:22:10
 catalog    | 0008_product_add_weight          | 2025-03-14 09:10:04
 users      | 0002_add_avatar                  | 2025-03-01 11:45:22
 users      | 0001_initial                     | 2025-02-20 08:30:11
Enter fullscreen mode Exit fullscreen mode

Schema Migrations vs Data Migrations

There are two fundamentally different kinds of migrations:

Schema migrations alter the database structure — adding or removing tables, adding or removing columns, creating indexes, changing column types. These are generated automatically by makemigrations.

Data migrations modify the data inside existing tables — backfilling a new column, transforming existing values, splitting one table into two. These must be written by hand using RunPython or RunSQL.

The golden rule: always separate schema migrations from data migrations. One migration should do one thing.

The Migration Graph

Migration files are not executed linearly by file name. Django builds a Directed Acyclic Graph (DAG) of all migrations based on their dependencies declarations, then executes them in topological order.

Initial (0001) → Add email (0002) → Add avatar (0003)
                                             ↓
                                    Merge (0004_merge) ← Add phone (0003b — parallel branch)
Enter fullscreen mode Exit fullscreen mode

This graph is what allows Django to handle branch merges, detect conflicts, and squash migrations without losing history.


Architecture Design

The Five Internal Components

Django's migration system has five key internal components that work together:

┌─────────────────────────────────────────────────────────────┐
│                   python manage.py makemigrations            │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
         ┌─────────────────────────┐
         │      AUTODETECTOR        │
         │  Compares current model  │
         │  state to last migration │
         │  state → finds changes   │
         └──────────┬──────────────┘
                    │ list of changes
                    ▼
         ┌─────────────────────────┐
         │       OPTIMIZER          │
         │  Reduces redundant ops:  │
         │  AddField+RemoveField    │
         │  → no-op                 │
         └──────────┬──────────────┘
                    │ optimised ops
                    ▼
         ┌─────────────────────────┐
         │        WRITER            │
         │  Generates the Python    │
         │  migration file on disk  │
         └─────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   python manage.py migrate                   │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
         ┌─────────────────────────┐
         │        LOADER            │
         │  Reads all migration     │
         │  files → builds the DAG  │
         └──────────┬──────────────┘
                    │
                    ▼
         ┌─────────────────────────┐
         │       EXECUTOR           │
         │  Checks django_migrations│
         │  table, runs unapplied   │
         │  migrations in order     │
         └──────────┬──────────────┘
                    │
                    ▼
         ┌─────────────────────────┐
         │      RECORDER            │
         │  Inserts rows into the   │
         │  django_migrations table │
         └─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Understanding these components is what lets you debug migration failures, resolve conflicts, and override default behaviour when needed.

The ProjectState Object

At its core, the migration system works by replaying the entire history of migration files to reconstruct a model of your database schema — the ProjectState. This is how makemigrations knows what changed: it computes ProjectState from existing migrations, compares it to your current models.py, and generates operations for the difference.

This is also why migrations must use apps.get_model() inside RunPython functions rather than importing models directly — the migration is running against a historical version of the model, not the current one.


Step-by-Step Implementation

Step 1: Initial Migration

# After defining your models.py, generate the initial migration
python manage.py makemigrations users --name initial

# Inspect what SQL it will generate before applying
python manage.py sqlmigrate users 0001_initial

# Apply to development database
python manage.py migrate users
Enter fullscreen mode Exit fullscreen mode

Step 2: Evolving Your Schema

# After changing models.py (adding a field, etc.)
python manage.py makemigrations users --name add_phone_number

# Always inspect before applying
python manage.py sqlmigrate users 0002_add_phone_number

# Apply
python manage.py migrate users 0002_add_phone_number

# Roll back if needed
python manage.py migrate users 0001_initial
Enter fullscreen mode Exit fullscreen mode

Step 3: Creating a Data Migration

# Create an empty migration for manual data transformation
python manage.py makemigrations users --empty --name backfill_username_from_email
Enter fullscreen mode Exit fullscreen mode

Step 4: Writing the Data Migration

# apps/users/migrations/0003_backfill_username_from_email.py
from django.db import migrations


def forwards(apps, schema_editor):
    """
    Backfill username field from email for existing users.

    IMPORTANT: Always use apps.get_model() inside RunPython functions.
    Never import the model directly — this migration must work against
    the historical model state, not the current one.
    """
    User = apps.get_model("users", "User")
    db_alias = schema_editor.connection.alias

    # Update in batches — never load all rows into memory
    batch_size = 1000
    qs = User.objects.using(db_alias).filter(username="").order_by("id")

    while True:
        batch_ids = list(qs.values_list("id", flat=True)[:batch_size])
        if not batch_ids:
            break

        users = User.objects.using(db_alias).filter(id__in=batch_ids)
        for user in users:
            user.username = user.email.split("@")[0]

        User.objects.using(db_alias).bulk_update(users, ["username"])


def backwards(apps, schema_editor):
    """
    Reverse: clear the backfilled usernames.
    Always provide a reverse function — it enables rollback.
    """
    User = apps.get_model("users", "User")
    db_alias = schema_editor.connection.alias
    User.objects.using(db_alias).update(username="")


class Migration(migrations.Migration):

    dependencies = [
        ("users", "0002_add_username_field"),
    ]

    operations = [
        migrations.RunPython(forwards, reverse_code=backwards),
    ]
Enter fullscreen mode Exit fullscreen mode

Step 5: Squashing Old Migrations

As migration files accumulate over months, running all of them from scratch becomes slow. Squash them periodically:

# Squash migrations 0001 through 0020 into one file
python manage.py squashmigrations users 0001 0020

# This creates: 0001_squashed_0020_....py
# The old files are kept until all environments have applied the squashed migration
# Once confirmed, delete the old files and the replaces[] attribute from the squash

# Test the squash on a fresh database
python manage.py migrate --run-syncdb   # or fresh migrate on a test DB
Enter fullscreen mode Exit fullscreen mode

Code Examples

Full Production Migration Anatomy

# apps/orders/migrations/0015_add_external_reference_and_index.py
from django.db import migrations, models


class Migration(migrations.Migration):
    """
    Adds an external_reference field for payment gateway correlation,
    plus a database index for lookup performance.

    This migration is safe to run during rolling deployments because:
    - Adding a nullable column does not affect existing INSERTs
    - Adding a concurrent index does not lock the table (PostgreSQL)
    """

    dependencies = [
        ("orders", "0014_add_shipping_notes"),
    ]

    operations = [
        # Step 1: Add column as nullable first
        migrations.AddField(
            model_name="order",
            name="external_reference",
            field=models.CharField(
                max_length=255,
                null=True,
                blank=True,
                help_text="Payment gateway transaction reference",
            ),
        ),
        # Step 2: Add index for fast lookups
        migrations.AddIndex(
            model_name="order",
            index=models.Index(
                fields=["external_reference"],
                name="idx_order_external_ref",
            ),
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

The Expand-Contract Pattern: Renaming a Column Safely

Renaming a column is the operation most likely to cause production downtime. The safe approach uses three deployments:

# ── PHASE 1: ADD the new column alongside the old one ──
# Migration: apps/users/migrations/0010_add_full_name.py

from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [("users", "0009_add_bio")]

    operations = [
        migrations.AddField(
            model_name="user",
            name="full_name",         # ← new column
            field=models.CharField(max_length=255, null=True, blank=True),
        ),
    ]
Enter fullscreen mode Exit fullscreen mode
# ── PHASE 1: Dual-write in application code (deployed with above migration) ──

# apps/users/models.py
class User(AbstractUser):
    display_name = models.CharField(max_length=255)  # ← old column
    full_name    = models.CharField(max_length=255, null=True, blank=True)  # ← new

    def save(self, *args, **kwargs):
        # Write to both columns during transition period
        if self.display_name and not self.full_name:
            self.full_name = self.display_name
        super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode
# ── PHASE 2: Backfill the new column with existing data ──
# Migration: apps/users/migrations/0011_backfill_full_name.py

from django.db import migrations
from django.db.models import F


def backfill_full_name(apps, schema_editor):
    User = apps.get_model("users", "User")
    db_alias = schema_editor.connection.alias
    # Single efficient UPDATE — no Python loop needed
    User.objects.using(db_alias).filter(
        full_name__isnull=True
    ).update(full_name=F("display_name"))


class Migration(migrations.Migration):
    dependencies = [("users", "0010_add_full_name")]

    operations = [
        migrations.RunPython(backfill_full_name, migrations.RunPython.noop),
    ]
Enter fullscreen mode Exit fullscreen mode
# ── PHASE 3: Switch app code to use new column, deploy, then remove old column ──
# Migration: apps/users/migrations/0012_remove_display_name.py
# Only run this AFTER Phase 2 is deployed and old containers are retired

from django.db import migrations


class Migration(migrations.Migration):
    dependencies = [("users", "0011_backfill_full_name")]

    operations = [
        migrations.RemoveField(
            model_name="user",
            name="display_name",     # ← removed only after app no longer reads it
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

SeparateDatabaseAndState: Advanced State Control

For complex scenarios where you need to change the database state differently from Django's model state (e.g., using PostgreSQL-specific DDL for performance):

# apps/catalog/migrations/0025_add_search_index_concurrently.py
from django.db import migrations


class Migration(migrations.Migration):
    """
    Adds a GIN index for full-text search using CONCURRENTLY to avoid
    table locking. Django's AddIndex doesn't generate CONCURRENTLY by default.

    SeparateDatabaseAndState lets us execute custom SQL while keeping
    Django's migration state in sync.
    """
    dependencies = [("catalog", "0024_product_add_search_vector")]
    atomic = False  # ← REQUIRED for CONCURRENTLY — can't be inside a transaction

    operations = [
        migrations.SeparateDatabaseAndState(
            # What to do in the database (custom SQL)
            database_operations=[
                migrations.RunSQL(
                    sql="""
                        CREATE INDEX CONCURRENTLY IF NOT EXISTS
                        idx_product_search_gin
                        ON catalog_product
                        USING GIN (search_vector);
                    """,
                    reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS idx_product_search_gin;",
                ),
            ],
            # What to tell Django's migration state (keep it in sync)
            state_operations=[
                migrations.AddIndex(
                    model_name="product",
                    index=models.Index(
                        fields=["search_vector"],
                        name="idx_product_search_gin",
                    ),
                ),
            ],
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

Resolving Migration Conflicts

When two developers both run makemigrations on the same app simultaneously, Django creates a conflict:

# Two conflicting migrations exist:
# users/migrations/0005_add_phone.py (Developer A)
# users/migrations/0005_add_avatar.py (Developer B)

# Django detects this:
$ python manage.py migrate
CommandError: Conflicting migrations detected; multiple leaf nodes in the
migration graph: (0005_add_phone, 0005_add_avatar).
To fix them run 'python manage.py makemigrations --merge'.

# Fix by merging:
$ python manage.py makemigrations --merge users --name merge_phone_and_avatar
# Creates: users/migrations/0006_merge_phone_and_avatar.py
# which declares both 0005s as dependencies — resolving the conflict
Enter fullscreen mode Exit fullscreen mode

Checking Migration Status

# Show all migrations and their applied status
python manage.py showmigrations

users
  [X] 0001_initial
  [X] 0002_add_avatar
  [X] 0003_backfill_username
  [ ] 0004_add_phone_number    ← not yet applied

# Check if any migrations are missing (useful in CI)
python manage.py migrate --check
# Returns exit code 1 if there are unapplied migrations

# Verify model state matches migrations (detect manual DB edits)
python manage.py migrate --run-syncdb --check
Enter fullscreen mode Exit fullscreen mode

CI Guard: Catch Missing Migrations Before They Reach Production

# .github/workflows/ci.yml
name: Django CI

on: [push, pull_request]

jobs:
  migrations:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements/test.txt

      - name: Check for missing migrations
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost/test
          DJANGO_SETTINGS_MODULE: config.settings.test
        run: |
          # Fails if makemigrations would generate new files
          # Catches the "forgot to commit your migration" error
          python manage.py makemigrations --check --dry-run

      - name: Verify migrations apply cleanly
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost/test
          DJANGO_SETTINGS_MODULE: config.settings.test
        run: |
          python manage.py migrate
          # Verify no unapplied migrations remain
          python manage.py migrate --check
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

1. Never Lock Tables on Large Rows: Always Add Nullable First

On older databases, adding a field with a default value may cause a full rewrite of the table. This happens even for nullable fields and may have a negative performance impact. To avoid that, follow this pattern:

# BAD — on MySQL and older PostgreSQL, this rewrites every row
migrations.AddField(
    model_name="order",
    name="priority",
    field=models.IntegerField(default=0),  # ← rewrites entire table!
)

# GOOD — add nullable, then backfill, then make non-nullable
# Migration 1: Add nullable
migrations.AddField(
    model_name="order",
    name="priority",
    field=models.IntegerField(null=True),  # ← no table rewrite
)

# Migration 2: Backfill (in batches, in a separate migration)
# Migration 3: Make non-nullable (after backfill is complete)
migrations.AlterField(
    model_name="order",
    name="priority",
    field=models.IntegerField(default=0, null=False),
)
Enter fullscreen mode Exit fullscreen mode

2. Add Indexes Concurrently (PostgreSQL)

On large tables, CREATE INDEX takes an ACCESS SHARE lock that blocks writes. Use CONCURRENTLY to build the index without blocking:

# apps/orders/migrations/0030_add_status_index.py
from django.db import migrations


class Migration(migrations.Migration):
    atomic = False  # Required for CONCURRENTLY

    dependencies = [("orders", "0029_...")]

    operations = [
        migrations.RunSQL(
            sql="""
                CREATE INDEX CONCURRENTLY IF NOT EXISTS
                idx_order_status_created
                ON orders_order (status, created_at DESC);
            """,
            reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS idx_order_status_created;",
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

3. Batch Large Data Migrations

Never process millions of rows in a single migration without batching. A long-running migration keeps a transaction open, which can cause lock contention and replication lag:

def migrate_in_batches(apps, schema_editor):
    """
    Process rows in chunks to avoid holding an open transaction too long.
    For 10M rows with chunk_size=5000: 2,000 transactions instead of 1 giant one.
    """
    Order = apps.get_model("orders", "Order")
    db_alias = schema_editor.connection.alias

    processed = 0
    chunk_size = 5_000

    while True:
        # Fetch a batch of IDs that need processing
        batch = list(
            Order.objects.using(db_alias)
            .filter(new_field__isnull=True)
            .values_list("id", flat=True)
            [:chunk_size]
        )

        if not batch:
            break

        # Process the batch
        Order.objects.using(db_alias).filter(
            id__in=batch
        ).update(new_field=... )

        processed += len(batch)
        print(f"Processed {processed} rows...")
Enter fullscreen mode Exit fullscreen mode

4. Squash Migrations to Reduce Startup Time

A large number of migrations increases Django's startup time because the migration loader must read and parse every file. Squash periodically:

# Squash the first 50 migrations in an app into one
python manage.py squashmigrations myapp 0001 0050

# Safe process:
# 1. Run squashmigrations → creates squash file with replaces=[]
# 2. Deploy the squash file (leave old files in place)
# 3. Wait until ALL environments have applied the squash
# 4. Delete old migration files
# 5. Remove replaces=[] from the squash file
# 6. Deploy the cleanup
Enter fullscreen mode Exit fullscreen mode

5. Generate SQL for Review Before Applying

On production, always preview the SQL before executing, especially for migrations on large tables:

# See exactly what SQL the migration will run
python manage.py sqlmigrate orders 0025_add_index

# Output:
# --
# -- Add index to orders_order
# --
# CREATE INDEX "idx_order_status" ON "orders_order" ("status");
# COMMIT;
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Always Backup Before Migrating in Production

# PostgreSQL: Create a backup before applying destructive migrations
pg_dump -Fc -h $DB_HOST -U $DB_USER $DB_NAME > "backup_$(date +%Y%m%d_%H%M%S).dump"

# Verify the backup is restorable before proceeding
pg_restore --list backup.dump | head -20

# Only then run the migration
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

2. Never Import Live Models in Migrations — Use apps.get_model()

# DANGEROUS — imports the current model, not the historical one
# If the model changes in the future, this migration breaks
from apps.users.models import User  # ← never do this inside a migration

def bad_migration(apps, schema_editor):
    User.objects.filter(is_active=False).update(status="inactive")

# SAFE — uses the historical model state frozen at this point in history
def safe_migration(apps, schema_editor):
    User = apps.get_model("users", "User")  # ← always do this
    db_alias = schema_editor.connection.alias
    User.objects.using(db_alias).filter(is_active=False).update(status="inactive")
Enter fullscreen mode Exit fullscreen mode

3. Always Provide reverse_code for Data Migrations

Without a reverse function, Django blocks rollback with an exception. This prevents emergency recovery.

from django.db import migrations


def forward(apps, schema_editor):
    Product = apps.get_model("catalog", "Product")
    Product.objects.using(schema_editor.connection.alias).filter(
        status="draft"
    ).update(is_published=False)


def backward(apps, schema_editor):
    Product = apps.get_model("catalog", "Product")
    Product.objects.using(schema_editor.connection.alias).filter(
        is_published=False
    ).update(status="draft")


class Migration(migrations.Migration):
    dependencies = [("catalog", "0007_add_is_published")]

    operations = [
        migrations.RunPython(forward, reverse_code=backward),
        # If reversal is genuinely impossible, use:
        # reverse_code=migrations.RunPython.noop
        # This allows rollback without restoring data.
    ]
Enter fullscreen mode Exit fullscreen mode

4. Validate Migrations in CI — Never Trust Local State

# config/settings/test.py
# Use PostgreSQL in CI, not SQLite
# SQLite has very little built-in schema alteration support —
# migrations that work in SQLite may fail on PostgreSQL in production

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("TEST_DB_NAME", "test_db"),
        "USER": os.environ.get("TEST_DB_USER", "postgres"),
        "PASSWORD": os.environ.get("TEST_DB_PASSWORD", ""),
        "HOST": os.environ.get("TEST_DB_HOST", "localhost"),
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Use atomic=False Only When Required, and Document Why

Migrations run inside a transaction by default. This means a failed migration is fully rolled back. Setting atomic=False disables this protection — only do it when the operation requires it (e.g., CREATE INDEX CONCURRENTLY) and always explain why:

class Migration(migrations.Migration):
    # IMPORTANT: atomic=False is required because CREATE INDEX CONCURRENTLY
    # cannot run inside a transaction. This means if this migration fails,
    # you must manually drop the partial index before re-running.
    # Check: SELECT * FROM pg_stat_progress_create_index;
    # Drop partial: DROP INDEX CONCURRENTLY IF EXISTS idx_name;
    atomic = False

    operations = [
        migrations.RunSQL(
            "CREATE INDEX CONCURRENTLY ...",
            "DROP INDEX CONCURRENTLY ...",
        )
    ]
Enter fullscreen mode Exit fullscreen mode

Common Developer Mistakes

❌ Mistake 1: Editing an Applied Migration

# BAD — never edit a migration file after it has been applied anywhere
# (development, staging, production, another developer's laptop)
# Once applied, a migration is part of the historical record.
# Edit it and you create inconsistencies that are very hard to resolve.

# GOOD — always create a new migration for any change that needs to be made
# after deployment, ensuring all environments remain consistent.
python manage.py makemigrations --name fix_field_max_length
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 2: Not Committing Migrations With Model Changes

# BAD — commit models.py but not the migration
git add apps/users/models.py
git commit -m "Add phone_number field to User"
# Other developers pull this, run migrate, and get nothing
# Production pulls this, runs migrate, and gets nothing
# Bugs appear that seem to violate the model definition

# GOOD — always commit model and migration together
git add apps/users/models.py apps/users/migrations/0003_add_phone_number.py
git commit -m "Add phone_number field to User (with migration)"
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 3: Running Data Migrations Against Live Model Imports

# BAD — uses the current model, breaks if the model changes later
def forwards(apps, schema_editor):
    from apps.orders.models import Order    # ← wrong
    Order.objects.filter(...).update(...)

# GOOD — frozen historical model
def forwards(apps, schema_editor):
    Order = apps.get_model("orders", "Order")   # ← correct
    Order.objects.using(schema_editor.connection.alias).filter(...).update(...)
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 4: Dropping a Column in a Single Deployment

# BAD — removes the column in one step
# Old containers still querying this column → immediate 500 errors
migrations.RemoveField(model_name="user", name="legacy_id")

# GOOD — follow the expand-contract pattern:
# Phase 1: Stop using the column in code (deploy)
# Phase 2: Make column nullable (deploy)
# Phase 3: Remove the column (deploy, after old containers are gone)
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 5: Running Migrations in Tests Without PostgreSQL

# BAD — test settings use SQLite
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
# Migrations work in tests but fail on production PostgreSQL
# because SQLite silently ignores constraints Django PostgreSQL enforces strictly

# GOOD — use PostgreSQL in CI to catch real migration failures early
# SQLite has very little built-in schema alteration support
# and should never be used to test production-bound migrations
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 6: Putting Business Logic in RunPython Functions

# BAD — calling external services or doing complex calculations in migrations
def bad_migration(apps, schema_editor):
    import requests
    Product = apps.get_model("catalog", "Product")
    for product in Product.objects.all():
        response = requests.get(f"https://api.exchange.com/price/{product.id}")
        product.usd_price = response.json()["price"]
        product.save()  # ← N+1 + external HTTP in a migration = disaster

# GOOD — migrations should be self-contained, deterministic transformations
# Use management commands or Celery tasks for complex business logic
# Keep migrations as simple SQL transformations
Enter fullscreen mode Exit fullscreen mode

Real Production Use Cases

Use Case 1: Zero-Downtime Field Removal (3-Phase Deploy)

A team needs to remove the deprecated referral_code field from their User model on a system with 8 million users and zero-downtime requirement.

Phase 1 (Week 1): Make the field nullable and stop writing to it.

# Migration: make nullable
migrations.AlterField(
    model_name="user",
    name="referral_code",
    field=models.CharField(max_length=50, null=True, blank=True),
)
# Code: remove all writes to referral_code in views/services
Enter fullscreen mode Exit fullscreen mode

Phase 2 (Week 2): Migrate code to stop reading the field.

# No migration needed — just remove all reads from referral_code in code
# Deploy: verify no references remain in code
grep -r "referral_code" apps/  # should return nothing
Enter fullscreen mode Exit fullscreen mode

Phase 3 (Week 3): Drop the column after confirming no old containers running.

migrations.RemoveField(model_name="user", name="referral_code")
Enter fullscreen mode Exit fullscreen mode

Total downtime: zero. Total risk: minimal (each phase is independently reversible).

Use Case 2: Backfilling a New Non-Nullable Column on 50M Rows

A team adds a region field (non-nullable) to an Order model with 50 million rows.

# Migration 1: Add as nullable
migrations.AddField(
    model_name="order",
    name="region",
    field=models.CharField(max_length=10, null=True),
)
Enter fullscreen mode Exit fullscreen mode
# Migration 2: Backfill in batches (runs as a management command, not in a migration)
# Reason: 50M rows takes 20+ minutes — too long for a migration transaction
# Run this as: python manage.py backfill_order_regions

from django.core.management.base import BaseCommand
from apps.orders.models import Order
from apps.orders.services import determine_region

class Command(BaseCommand):
    def handle(self, *args, **options):
        chunk_size = 10_000
        total = Order.objects.filter(region__isnull=True).count()
        processed = 0

        while True:
            batch = list(
                Order.objects.filter(region__isnull=True)
                .values_list("id", "user__shipping_address__country")
                [:chunk_size]
            )
            if not batch:
                break

            updates = [
                Order(id=oid, region=determine_region(country))
                for oid, country in batch
            ]
            Order.objects.bulk_update(updates, ["region"])
            processed += len(batch)
            self.stdout.write(f"Progress: {processed}/{total}")
Enter fullscreen mode Exit fullscreen mode
# Migration 3: After backfill is complete, make non-nullable
migrations.AlterField(
    model_name="order",
    name="region",
    field=models.CharField(max_length=10, null=False, default="unknown"),
)
Enter fullscreen mode Exit fullscreen mode

Use Case 3: Automated Migration CI/CD Pipeline

# .github/workflows/deploy.yml (simplified)
steps:
  - name: Check for missing migrations
    run: python manage.py makemigrations --check --dry-run

  - name: Run tests with fresh migrations
    run: |
      python manage.py migrate
      pytest

  - name: Deploy to staging
    run: deploy_to_staging.sh

  - name: Run pre-deploy migrations (safe — additive only)
    run: |
      # Only run migrations that ADD things (safe for rolling deploy)
      # RemoveField/DropTable migrations are deployed post-code-swap
      python manage.py migrate

  - name: Deploy new application version
    run: deploy_rolling.sh

  - name: Run post-deploy migrations (destructive — old code is gone)
    run: |
      # Column removal, index drops — safe after old containers are retired
      python manage.py migrate --run-syncdb
Enter fullscreen mode Exit fullscreen mode

Conclusion

Django migrations are a powerful, mature, and battle-tested system for managing database schema evolution. When you understand what they are — version-controlled, replay-based, DAG-ordered database change sets — the mechanics become predictable and the pitfalls become avoidable.

The most important principles from this guide:

Always separate schema from data migrations. A migration that adds a column and backfills it in a single file is harder to reason about, harder to roll back, and harder to safely deploy.

The expand-contract pattern is not optional in rolling deployments. Any destructive migration (removing a column, renaming a field) that runs during a rolling deployment will cause production errors for the duration of the rollout. Deploy it in phases.

Batch data migrations. Processing millions of rows in a single transaction holds locks for minutes, causes replication lag, and cannot be partially rolled back. Process in chunks of a few thousand.

Test migrations against PostgreSQL in CI, never just SQLite. SQLite has very little built-in schema alteration support. A migration that works in your local SQLite development environment can fail badly on production PostgreSQL.

Always provide reverse functions. A data migration without reverse_code blocks emergency rollback exactly when you need it most.

Migrations are the contract between your code and your data. Write them like they'll need to survive a production incident — because eventually, they will.


Further Reading


Written by a Python backend engineer building production Django systems. Topics: Django migrations, data migrations, zero-downtime deployments, rolling deployments, schema evolution, RunPython, squashmigrations, expand-contract pattern.

Top comments (0)