Module 2 · Databases

Lesson 11 — Versioned migrations

Zero-downtime schema changes: how to evolve TicketFlow with real data inside.

Published
In this lesson
  1. Exercise 1 — Expand-contract
  2. Exercise 2 — NOT NULL
  3. Exercise 3 — CONCURRENTLY
  4. Exercise 4 — The three improvements
  5. Professor's summary

Exercise 1 — Expand-contract

1-2. The batched data migration:

python
def copy_title(apps, schema_editor):
    Event = apps.get_model("events", "Event")
    qs = Event.objects.filter(label__isnull=True).order_by("pk")
    batch = 1000
    while True:
        items = list(qs[:batch])
        if not items:
            break
        for e in items:
            e.label = e.title
        Event.objects.bulk_update(items, ["label"], batch_size=batch)
  1. Dual write: in the model's save() or in the creation services both fields get set. The 3 test events have both columns equal.
  2. After CONTRACT (RemoveField of title), old code doing event.title raises FieldError/OperationalError: that is why step 4 only happens when NO pod runs old code (the deployment order is part of the design).

Exercise 2 — NOT NULL

  1. Django generates the AlterField with the default; modern PostgreSQL applies the default as metadata and then validates — but the default stays "stuck" to the schema (and with various versions/actions it can rewrite): the explicit path avoids surprises and documents the intent.
  2. Long path: nullable (instant) → batched backfill (non-blocking, controlled memory) → AlterField NOT NULL (validates with no pending backfill). With \timing you see the backfill is the slow part — and in batches it blocks nobody.
  3. The linter flags your history's NOT NULL AddFields, RemoveFields and type AlterFields: the list is your migration debt, for planning (not for panicking).

Exercise 3 — CONCURRENTLY

  1. Migration: class Migration: atomic = False; operations = [RunSQL("CREATE INDEX CONCURRENTLY idx_reservation_created ON events_reservation (created_at);", reverse_sql="DROP INDEX IF EXISTS idx_reservation_created;")]
  2. Without CONCURRENTLY, CREATE INDEX takes a lock blocking the table's INSERT/UPDATE for the build's duration (on big tables: seconds-minutes of API outage). If a CONCURRENTLY fails halfway, an INVALID index remains that takes space and is NOT used — you must DROP and retry.
  3. The SQL check returns the invalid indexes; in CI or after big deploys it is a good smoke test.

Exercise 4 — The three improvements

  1. NOT NULL: nullable → backfill (sensible values: created_at for empty updated_at, etc.) → AlterField.
  2. CHECK expires_at > created_at: first fix old rows violating it (are there any? SELECT COUNT(*) WHERE NOT (expires_at > created_at)); then AddConstraint. If there are violators, the migration fails on apply — hence the order: fix, then constrain.
  3. Slug: nullable → backfill (slugify(title) + short id, guaranteeing uniqueness with retry on collision) → UNIQUE. (The UniqueConstraint applies after the backfill so it doesn't fail.)

Professor's summary

  • Migration = versioned history: one intention, always a reverse, deployed ones are untouchable.
  • Expand → dual write → switch → contract: the pattern that renames without shutting anything down.
  • The blocking stuff (blind NOT NULLs, big indexes, ALTER TYPE) goes the long, measured way.
  • apps.get_model and batches in RunPython: determinism and memory.

After the correction: Lesson 12 — NoSQL, closing the data module.