09 · Zero-Downtime Deployments & Migrations¶
Shipping a change to a live API without an outage — no dropped requests, no downtime window, no "maintenance mode" — requires deliberate technique at both the deployment and database layers.
Rolling deployments¶
t0: [old, old, old, old] 4 instances, all serving traffic
t1: [new, old, old, old] 1 instance replaced, health-checked before receiving traffic
t2: [new, new, old, old]
t3: [new, new, new, old]
t4: [new, new, new, new] fully rolled out
The load balancer only routes traffic to instances passing health checks; a new instance that fails its health check is never sent live traffic, and the rollout halts automatically rather than taking the whole fleet down with a bad build.
The danger window: two versions running at once¶
During a rolling deploy, both old and new code are serving traffic simultaneously — for anywhere from seconds to minutes. Any change that isn't compatible with itself running alongside the previous version breaks requests during that window, even though the final state (all-new) is fine.
The expand-contract pattern for database migrations¶
Renaming a column outright breaks whichever code version doesn't expect the new name, for the entire rollout window. The safe path splits it into independent, individually-safe deploys:
Expand — add the new column, write to both, without removing the old one yet:
def create_order(total):
db.execute(
"INSERT INTO orders (total, total_amount) VALUES (%s, %s)",
total, total # write both columns during the transition
)
Deploy this. Old and new instances both work fine — old code doesn't
know total_amount exists and doesn't need to.
Migrate — backfill existing rows, then switch reads to the new column:
Deploy this only after every row is backfilled — otherwise old rows
read as null.
Contract — once every instance is running code that only uses
total_amount, and enough time has passed that you're confident
nothing external still depends on total, drop the old column:
Three independent deploys, each individually safe with both old and new code running simultaneously — versus one deploy that's broken for the entire rollout window.
API-level compatibility during rollout¶
The same principle applies to the API surface, not just the database: a response field being added or removed must also survive the mixed- version window, which is exactly why module 6 (Level 3) insists new fields are additive and old fields aren't dropped until a full deprecation cycle completes — a rolling deploy is a live, if brief, demonstration of the same compatibility rules deprecation policy enforces over a longer timescale.
Blue-green deployment: an alternative to rolling¶
Blue (live, v1) ← 100% traffic
Green (staged, v2) ← 0% traffic, fully deployed, health-checked
# cutover: flip traffic atomically
Blue (v1) ← 0% traffic
Green (v2) ← 100% traffic
No mixed-version window at all — traffic switches from one complete environment to another in one step. Trades the rolling deploy's gradual exposure (a bad build only affects a fraction of instances briefly) for an instant, all-or-nothing cutover that's trivially reversible (flip back to blue) if something's wrong.
Worked example: a real migration end to end¶
Renaming orders.total to orders.total_amount across an API with
multiple running instances:
- Deploy 1 (expand): add
total_amount, write to both columns, API still returnstotalonly. Safe during rollout — no reader depends ontotal_amountyet. - Backfill: run the
UPDATEmigration against existing rows, off-peak, in batches to avoid locking the table. - Deploy 2 (migrate reads, add field): API response now includes
both
totalandtotal_amount; internal reads usetotal_amount. - Deprecation window (Level 3, module 6): announce
totalas deprecated with a sunset date; monitor which clients still read it. - Deploy 3 (contract): once usage of
totaldrops to zero (or the sunset date passes), remove it from the response and drop the column.
How It Actually Works¶
Zero-downtime schema migrations work by never having a single moment where old code and new code disagree about what the database looks like — mechanically, this means splitting one conceptual change into several independently-safe deploy steps.
Renaming a column, done unsafely, is a single ALTER TABLE ... RENAME
COLUMN — the instant it runs, any still-running old server process whose
code references the old column name starts throwing SQL errors on every
query, because the column it expects literally no longer exists. Done
safely (the "expand-contract" pattern):
1. EXPAND: add new column `full_name`, keep old `name`. Deploy.
(old code writes `name`; new column sits unused)
2. BACKFILL: run a background job copying name -> full_name for existing
rows, in batches (to avoid locking the whole table at once).
3. DUAL-WRITE: deploy code that writes to BOTH `name` and `full_name`.
Old and new code can coexist because both columns exist and are kept
in sync.
4. SWITCH READS: deploy code that reads from `full_name` only.
5. CONTRACT: once all instances run the new code and no rollback risk
remains, drop the old `name` column.
Each step is independently deployable and independently reversible — a rollback mid-migration at any step still has a valid, consistent database state, because at every step both the old and new server code (which will briefly coexist during a rolling deploy across many instances) can successfully read and write against the schema as it exists at that moment. Collapsing steps (skipping dual-write, say) reintroduces exactly the failure window this pattern exists to eliminate — a request hitting an old-code instance after the schema changed underneath it.
Exercise¶
- Why does renaming a column in one step break requests during a rolling deploy, even though the deploy fully succeeds in the end?
- Explain what would go wrong if step 2 (migrate reads) shipped before step 1's expand deploy had reached 100% of instances.
- Compare rolling deployment and blue-green: which limits the blast radius of a bad build better, and which makes rollback faster?
- Design the expand-contract steps for adding a
NOT NULLconstraint to an existing nullable column without downtime.