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 addNOT NULLor 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 filledRemember
- 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.