Stack: Django 5 + PostgreSQL · Project: TicketFlow Status: Published Prerequisite: Lesson 10 — Transactions
Objectives
- Treat migrations as versioned history (never edit what is deployed).
- Apply the expand-contract pattern for zero-downtime changes.
- Detect dangerous migrations before they take production down.
- Apply Lesson 08's three improvements to TicketFlow with safe migrations.
1. History as a contract
makemigrations generates versioned files in the repo. Rules of the craft:
- Deployed migrations are never edited: if a migration already ran in production, its fix is a NEW migration (never touch the old file).
- One migration, one intention: "adds field X" — not "Saturday's reshuffle" (irreversible and unreviewable).
migrations.RunPython.noopin the reverse: every data migration defines its way back (even if noop); without a reverse there is no deploy rollback.- Squash with judgement: after accumulating hundreds,
squashmigrationscondenses an app's history — only when the old ones no longer run in any live environment.
2. Expand-contract: changing without falling over
Zero-downtime deploys demand that the old and new versions coexist for a few minutes (N pods deploying in a roll). The pattern:
1. EXPAND add the new thing (nullable column, table, index) — compatible with the old code
2. DUAL WRITE / migrate data the new code writes old+new; a job backfills the history
3. SWITCH the code reads/writes only the new thing
4. CONTRACT drop the old thing (column/constraint) — when no pod runs the old codeReal example: rename events_event.name → title. Wrong way: a direct rename_field (the old version fails because it can't see the column). Expand-contract way: add title, double write, switch, then RemoveField(name).
The detail that sinks deployments: adding a NOT NULL column without a default or creating a blocking index. On modern PostgreSQL: ADD COLUMN... DEFAULT... NOT NULL is metadata (fast), but ALTER COLUMN TYPE or a FK without a prior index blocks. Rule: CREATE INDEX CONCURRENTLY (doesn't block writes; cannot run inside a transaction → atomic = False on the migration).
3. Data migrations: in Python, not loose SQL
def set_default_state(apps, schema_editor):
Event = apps.get_model("events", "Event")
Event.objects.filter(state__isnull=True).update(state=EventState.DRAFT)
class Migration(migrations.Migration):
dependencies = [("events", "0004_previous")]
operations = [
migrations.RunPython(set_default_state, migrations.RunPython.noop),
]apps.get_model (not the direct import): the historical version of the model, not the current one. Batch with .iterator() + pagination on big tables (don't load 2M rows into memory). Dangerous data migrations (massive regexes, external calls) → never: a migration is deterministic and local.
4. The three dangers (and their lint)
| Change | Risk | Safe path |
|---|---|---|
| ADD COLUMN NOT NULL without default | blocking or failing on old rows | nullable → backfill → NOT NULL |
| DROP COLUMN | in-flight old code uses it | full expand-contract first |
| ALTER COLUMN TYPE | table rewrite, blocking | new column + dual write + switch |
| CREATE INDEX (big) | blocks writes | CONCURRENTLY |
Tool: django-migration-linter flags dangerous operations in CI. Your deployment checklist: lint in the pipeline → migrate with --noinput after a backup → smoke test the health check (Lesson 02) → rollback = code revert + migrations designed to be backward-compatible one step.
5. Application: Lesson 08's improvements
The three improvements you noted (NOT NULLs, coherence CHECK, unique slug) are applied expand-contract: each one with its migration, data backfill, and green lint. The exercises walk you through it step by step.
Self-assessment
- Why is editing an already-deployed migration sabotage even when "nobody has run it locally yet"?
- Explain expand-contract with the name→title rename, step by step, and say what each step breaks if you skip it.
- Why does
CREATE INDEX CONCURRENTLYdemandatomic = False, and what do you lose by leaving the transaction? - Why
apps.get_modeland notfrom events.models import Eventin a RunPython? - Your linter flags
RemoveFieldas dangerous. What is dangerous about dropping a column if the new code no longer uses it?
Continue with the exercises. The solutions only after trying it yourself.