Data migrations and zero-downtime changes

Django · Expert · 15 min

Schema changes on busy production tables are where deploys break; experts plan them in safe stages.

Besides schema operations, migrations can change data. Create an empty one with makemigrations blog --empty -n backfill_slugs and add a RunPython operation:

migrations.RunPython(forwards, backwards)

Inside forwards(apps, schema_editor), get models with `apps.get_model("blog", "Post")`, never by importing them. This returns the historical version of the model as it was at this point in the migration history; your current models.py might have fields that don't exist yet. Historical models have no custom methods and no custom managers unless marked use_in_migrations = True. Provide a reverse function (or RunPython.noop) so the migration can be unapplied.

For large tables, process rows in batches, and consider atomic = False on the migration class so a backfill doesn't hold one giant transaction. RunSQL runs raw SQL, and SeparateDatabaseAndState lets you change Django's model state without touching the database (or vice versa).

Zero-downtime schema changes split risky operations across deploys, because old and new code run side by side:

  • add a column: add it nullable (or with db_default), deploy code that writes it, backfill, then add NOT NULL or constraints
  • rename a column: add the new one, write both, backfill, switch reads, drop the old one later
  • remove a column: stop using it in code first, then drop it in a later release

On PostgreSQL, AddIndexConcurrently (from django.contrib.postgres.operations, in a non-atomic migration) builds indexes without locking writes. Always check sqlmigrate output for table rewrites and long locks.

Example

# blog/migrations/0007_backfill_slugs.py
from django.db import migrations
from django.utils.text import slugify

def forwards(apps, schema_editor):
    Post = apps.get_model("blog", "Post")                    # historical model
    batch = []
    for post in Post.objects.filter(slug="").only("id", "title").iterator(chunk_size=1000):
        post.slug = f"{slugify(post.title)[:40]}-{post.pk}"
        batch.append(post)
        if len(batch) == 1000:
            Post.objects.bulk_update(batch, ["slug"])
            batch.clear()
    Post.objects.bulk_update(batch, ["slug"])

class Migration(migrations.Migration):
    atomic = False                                            # no single huge transaction
    dependencies = [("blog", "0006_post_slug_nullable")]
    operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

# next release: 0008 makes slug unique and NOT NULL once all rows are filled

Remember

  • RunPython with apps.get_model uses historical models, never imports
  • Provide reverse functions; batch large backfills, possibly with atomic = False
  • Split risky schema changes across releases: add, backfill, constrain, remove later

Watch out for

  • Importing models directly in a data migration, which breaks when the model changes later.

Check yourself

  1. Why use apps.get_model("blog", "Post") inside a RunPython function?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.djangoproject.com/en/6.0/howto/writing-migrations/

Builds on

Unlocks

Nothing builds on this yet.