Most Django migration advice is written for the happy case: one developer, one database, and a deploy that can afford thirty seconds of downtime. Production is usually less forgiving. Workers are running old code, a table has tens of millions of rows, and someone is still using the old mobile client.
The pattern I reach for is expand and contract. Instead of changing a schema in one leap, you make the change in stages that are safe at every point in between.
The shape of a safe migration
Suppose we want to rename customer_name to customer_full_name on a large table. A single RenameField migration is quick in development and a small disaster in production: old application instances still query the old column while the new code expects the new one.
Instead:
- Expand. Add the new column, nullable, with no default that rewrites the table.
- Dual-write. Deploy application code that writes to both columns and reads from the old one.
- Backfill. Copy existing values in batches, outside the deploy, with throttling.
- Switch reads. Deploy code that reads from the new column, keeping the old write for rollback safety.
- Contract. Remove the dual-write, then drop the old column in a later release.
At no point does old code and new schema have to agree on something they cannot both see.
# Step 1: expand
class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name="customer",
name="customer_full_name",
field=models.CharField(max_length=255, null=True),
),
]
The backfill is deliberately not a data migration inside the deploy. A long-running UPDATE holds locks, bloats the table, and makes rollback a question nobody wants to answer at 2 a.m. A management command with a batch size and a sleep between batches is less elegant and much easier to stop.
while True:
batch = list(
Customer.objects.filter(customer_full_name__isnull=True)
.values_list("pk", flat=True)[:1000]
)
if not batch:
break
for customer in Customer.objects.filter(pk__in=batch):
customer.customer_full_name = customer.customer_name
customer.save(update_fields=["customer_full_name"])
time.sleep(0.25)
Keep the deploy boring
A few rules that have saved me more than any clever tooling:
- Every migration must be reversible or explicitly marked as irreversible. "We'll restore from backup" is not a rollback plan for a schema change.
- Add indexes concurrently. On PostgreSQL, use
AddIndexConcurrentlyor raw SQL withCREATE INDEX CONCURRENTLYfor large tables. - Never add a non-null column without a default or a backfill plan. On older PostgreSQL versions this rewrites the whole table.
- Test against a production-sized copy. A migration that takes 200ms on an empty database can take twenty minutes on a real one.
- Deploy migrations separately from code when the change is risky. It is much easier to reason about one moving part.
Watch for the second-order effects
Schema changes do not happen in isolation. They interact with:
- Background workers that may still be processing old payloads.
- Cached serialised objects that carry the old field name.
- Search indexes and materialised views built from the old shape.
- Reports written by someone in finance two years ago and still run every Monday.
A grep for the old column name across the whole repository — not just the Django app — is cheap insurance.
A checklist for the deploy
- Can the old code run against the new schema? Can the new code run against the old schema?
- Is the backfill idempotent and restartable?
- Have we measured the migration on realistic data?
- Do we know how long the lock will be held, and what blocks behind it?
- Is there a point in the sequence where rollback is no longer safe? If so, say so in the release notes.
None of this makes migrations exciting. That is the goal. The best schema change is the one nobody notices.