anishfyi.com Projects All writings

January 5, 20269 min readDjango, Postgres, Migrations

Zero-Downtime Database Migrations in Django

Shipping schema changes to a live Postgres database without dropped requests or downtime windows.

A schema change on a live database is not just a code change. A careless migration can take a lock that makes every query on a table wait, and for a busy table that is a small outage. Zero-downtime migrations are mostly about avoiding that lock.

The migration that locks the table

Adding a column with a volatile default, or adding a constraint that forces the database to scan every row, holds a lock for the whole operation. On a table with millions of rows, every request that touches it queues behind that lock.

Add columns as nullable first

A nullable column with no default is a fast metadata-only change. It is the safe first step.

# step one: add the column as nullable
status = models.CharField(max_length=20, null=True)

Backfill in batches

Fill the new column in a separate data migration, in batches. One giant UPDATE locks the rows it touches; many small updates do not, and they can be paused or resumed.

Then enforce the constraint

Once every row has a value, a later migration flips the column to null=False. The expensive validation is now cheap, because there is nothing left to fix.

Build indexes concurrently

A plain CREATE INDEX locks writes to the table while it runs. Postgres can build it without that lock, and Django exposes it directly. Note the migration must opt out of running in a transaction.

from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class Migration(migrations.Migration):
    atomic = False

    operations = [
        AddIndexConcurrently(
            "order",
            models.Index(fields=["status"], name="order_status_idx"),
        ),
    ]

Renames and drops need two deploys

Never rename or drop a column in the same release that stops using it. Old application code is still running during a rolling deploy. Ship code that works with both shapes, let it settle, then change the schema. This is the expand-then-contract pattern, and it applies to every destructive change.

Decouple deploy from migrate

Run migrations as their own deliberate step, not as a side effect of a container starting. That way the sequence is something you control: expand the schema, deploy the code, migrate the data, and only later contract.

The checklist before I run one

Working on something in this territory? Book thirty minutes.