Exercise 1 — Expand-contract
1-2. The batched data migration:
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)- Dual write: in the model's
save()or in the creation services both fields get set. The 3 test events have both columns equal. - After CONTRACT (RemoveField of title), old code doing
event.titleraisesFieldError/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
- 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.
- Long path: nullable (instant) → batched backfill (non-blocking, controlled memory) → AlterField NOT NULL (validates with no pending backfill). With
\timingyou see the backfill is the slow part — and in batches it blocks nobody. - 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
- 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;")] - 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.
- The SQL check returns the invalid indexes; in CI or after big deploys it is a good smoke test.
Exercise 4 — The three improvements
- NOT NULL: nullable → backfill (sensible values: created_at for empty updated_at, etc.) → AlterField.
- CHECK
expires_at > created_at: first fix old rows violating it (are there any?SELECT COUNT(*) WHERE NOT (expires_at > created_at)); thenAddConstraint. If there are violators, the migration fails on apply — hence the order: fix, then constrain. - 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.