30 min read

Drill 14 — The Bad Deploy

A migration that locks the busiest table, a one-line bug that overwrites data for 40 minutes, a rollback that can't work, and a restore nobody has ever run

Time box: 45 minutes. Uses Ch 7 — Data Safety, Ch 8 — Observability and Recovery, Ch 6 — Graceful Degradation. Fences: timeline, investigation. Background: Debugging 15 — Incidents and postmortems, PostgreSQL First Principles, Q13 — Write-ahead log, SD 1 — Replication, WAL, lag, SD 2 Ch 18 — Observability.


How to Run This Drill

The other drills break a box: a cache, a primary, a downstream. This one breaks nothing. Every server stays up. A deploy changes the schema and the code together, and for 40 minutes the code writes wrong values over right ones. The error dashboard is green for most of the incident.

That is why interviewers like it. It tests whether your design can answer three questions that uptime can't: how would you know the data is wrong, how do you stop the damage spreading to things you can't undo, and can you actually get the old data back?

Read the prompt and the baseline. Start a 45-minute timer. At each break, answer the "Before you read on" questions out loud, in the order detect → contain → degrade → recover → prevent. Commit to each prediction before you step past it. Break 2 is a choose-your-next-check investigation: take your time on it, because the order of the checks is the lesson.


The Prompt

"You own the billing service of a B2B SaaS company. It stores customers, subscriptions and invoices in Postgres. Every afternoon a renewal sweep renews the day's subscriptions and issues their invoices. Invoices are emailed 10 minutes after they are created. Cards are charged at 18:00.

Scale: 1.2 million customers, 2 million subscriptions, about 3,000 API requests a second. The database is 400 GB, with one streaming replica.

Many customers have a negotiated price, typed in by sales and stored on the subscription. Everyone else pays the plan's list price.

Backups: a base backup every night at 02:00, and WAL archived continuously to object storage by a small script. Retention is 7 days. The runbook says a restore takes about an hour. Nobody has run one.

Target: 99.9% of API requests succeed, measured monthly.

At 14:00 today you deploy v2.31."

Numbers to write on the board first

QuantityValueWorking
Monthly error budget43.2 min of full outage0.1% × 30 days × 1,440 min
Burn rate at 80% errors800×80% ÷ 0.1%
Connections12 pods × 20 = 240max_connections is 300
Renewal sweepabout 5 renewals/s, 14:03–14:4312,000 renewals
Subscriptions with a negotiated priceabout 30%
WAL generatedabout 20 GB an hour
WAL to replay for a restore to mid-afternoonabout 240 GB02:00 → 14:00 = 12 h × 20 GB

The Baseline Design

ComponentWhat it doesWhat it assumes
MigrationsRun before the rollout. The migration tool wraps each one in a single transactionMigrations are quick
Rolling deployReplaces 2 pods a minute. Old and new code run side by side for about 6 minutesOld code works with the new schema
RollbackOne button: redeploy the previous imageThe previous code still works
ReplicaFor failover and read scaling
BackupsBase backup at 02:00, WAL archived by a scriptThe script reports failures, and a restore works
MonitoringError rate, latency, saturation, alerts on the SLOWrong data shows up as errors

Break 1 — The Error Rate Spikes During the Deploy

"At 14:00:40 the error rate goes from 0.1% to 80%, on every endpoint, including ones the release didn't touch. At 14:03 it drops to about 40%, and it falls to normal by 14:09. Here's the migration that ran at 14:00."

sql
-- 0142_subscription_amounts.sql (the tool runs it in one transaction)
ALTER TABLE subscriptions ADD COLUMN unit_amount_cents bigint;
UPDATE subscriptions SET unit_amount_cents = round(price * 100);   -- 2M rows
ALTER TABLE subscriptions ALTER COLUMN unit_amount_cents SET NOT NULL;
ALTER TABLE subscriptions DROP COLUMN price;

Before you read on:

  1. Why do endpoints that never read unit_amount_cents fail too?
  2. Why does the error rate drop to 40% at 14:03 instead of to zero?
  3. How much of the month's error budget did this cost?
#
Time
Migration 0142
subscriptions table
API pods
Error rate
1
14:00:00
BEGIN, ADD COLUMN
ACCESS EXCLUSIVE lock, held until COMMIT
12 × v2.30
0.1%
1 / 10
Answer

Detect. The error-rate alert with a deploy marker on the graph, which points at the release within seconds. In the database, pg_stat_activity shows hundreds of sessions with wait_event_type = 'Lock', and pg_blocking_pids() names one blocker: the migration.

Contain. In phase 1, the fastest fix is to cancel the migration: SELECT pg_cancel_backend(<pid>). The transaction rolls back, the lock is released, and the waiting queries run within a second. The deploy is aborted, which is what you want. Once it has committed (phase 2), let the rollout finish, or speed it up. Rolling back would put all 12 pods on code that can't read the table (Break 3).

Degrade. Nothing useful to switch off here: almost every request touched the locked table through the auth middleware. That's a finding in itself. The auth path should not need a row from the billing table on every request.

The budget. Phase 1: about 2.5 minutes at roughly 85% errors ≈ 2.1 full-outage minutes, plus the ramp from 14:00:00 to 14:00:40. Call it 2.5. Phase 2: falling from 40% to 0 over 6 minutes, 20% on average, ≈ 1.2 minutes. Total about 3.7 of 43.2 minutes: 9% of the month's budget in 9 minutes. At the peak burn rate of 800×, the whole month's budget would last under an hour (720 h ÷ 800 ≈ 54 min).

Prevent.

  • lock_timeout on every migration session, for example SET lock_timeout = '2s'. A migration that can't get its lock quickly fails, instead of waiting at the head of the lock queue. That matters because a waiting ALTER TABLE also blocks every new query behind it. We checked this on Postgres 18: with a long SELECT holding the table, an ALTER TABLE with no lock timeout made a 1 ms read wait 3 seconds. With lock_timeout = '1s', the ALTER failed with "canceling statement due to lock timeout" and nothing queued behind it.
  • Never backfill inside the DDL transaction. Add the column alone (a nullable column with no default is a catalog change, done in milliseconds), then backfill in batches of a few thousand rows, each its own short transaction.
  • Make NOT NULL cheap. SET NOT NULL scans the whole table under the strongest lock. Instead, add CHECK (unit_amount_cents IS NOT NULL) NOT VALID, run VALIDATE CONSTRAINT (it takes a weaker lock that allows reads and writes), and then SET NOT NULL. On Postgres 12 and later it uses the valid constraint and skips the scan. On Postgres 18 the debug log says exactly that: "existing constraints on column … are sufficient to prove that it does not contain nulls".
  • Never drop a column in the release that stops using it. That's Break 3.

The follow-up: "You add lock_timeout = '2s'. Now the migration fails every afternoon, because a reporting query holds the table for 20 minutes. What now?"

Answer

Keep the short lock timeout, and retry the DDL in a loop with backoff: try for 2 s, wait 10–30 s with jitter, try again, maybe 30 times, then give up and alert. Each attempt blocks other queries for at most 2 s, which is a latency blip instead of an outage. Then deal with the cause: a 20-minute report on the primary's hottest table belongs on the replica. Raising the lock timeout is the wrong fix: it just brings back the queue of blocked requests behind the waiting ALTER.


Break 2 — The Data Is Wrong

"14:51. The error rate has been normal since 14:09. Support forwards three emails from enterprise customers: their renewal invoices this afternoon ignore their contracted price."

Before you read on:

  1. What is your first move, before you look for the bug?
  2. Which things in this system can't be undone by any database restore?
  3. How do you find out exactly which rows are wrong?

Work through the incident. Each check costs time, and the clock is the customers' money.

InvestigationTime spent: 0 min
Symptom
14:51. The error rate has been normal since 14:09. Support forwards three emails from enterprise customers: their renewal invoices, issued this afternoon, charge the full list price instead of the price in their contract.
1What do you check first?
Answer

The bug, for the record:

js
// v2.31 renewal (simplified)
const sub = await subscriptions.findById(id)   // the model no longer has `price`
const amount = sub.price == null               // meant: "no negotiated price"
  ? plan.listPriceCents                        // now true for every subscription
  : sub.unitAmountCents
await subscriptions.update(id, { unitAmountCents: amount })

Detect. Nothing paged, and that's the finding. Add a correctness signal next to the availability SLO: a check every 5 minutes that compares new invoices with their contracts and alerts when more than 0.1% disagree. It would have fired at about 14:08, 43 minutes before the support emails.

Contain. Stop the writes first, then the side effects. Of the three side effects, two are already out of your hands (emails sent, the data overwritten), and one isn't: the card charge at 18:00. At an average overcharge of about $240, 3,604 wrong charges would be about $865,000 taken from customers' cards, plus refunds, chargebacks and support. Holding the charge for the affected customers is the most valuable action of the whole incident.

Degrade. Plan changes are switched off for an afternoon. Customers who try see "Plan changes are temporarily unavailable" instead of a wrong price.

Recover. Breaks 3–5.

Prevent. The correctness check; side effects behind a delay and a switch (the 10-minute email delay helped nothing, because nobody was watching. A delay is only useful with a check that can stop the sending); and expand/contract, so a column the code relies on can't vanish in the same release.

The follow-up: "Your SLO dashboard was green for 40 minutes while you overwrote 3,604 prices. What SLOs should this service have?"

Answer

Availability and latency SLOs measure whether the service answers. This service also has to answer correctly, so give it a correctness SLI for its most important output: "the share of invoices whose amount matches the contract and plan", measured by an independent check, not by the code that wrote the invoice. Target 99.99%, with a burn-rate alert like any other SLO. Add a freshness SLI for the pipelines that matter ("the renewal sweep finished by 15:00"). An independent check is a few SQL queries on a schedule. It's cheap, and it catches the incident the other SLOs can't see (SD 2 Ch 18).


Break 3 — "Just Roll It Back"

"The CTO joins the call: 'Roll back to v2.30 and run the migration's down script. Done in five minutes.' Why not?"

sql
-- 0142 down
ALTER TABLE subscriptions ADD COLUMN price numeric(12,2);
UPDATE subscriptions SET price = unit_amount_cents / 100.0;   -- 2M rows
ALTER TABLE subscriptions DROP COLUMN unit_amount_cents;

Before you read on:

  1. What does v2.30 do against today's schema?
  2. Run the down script first. What value does it put back for the 3,604 wrong rows?
  3. What do you do instead, and how do you make the next migration rollback-safe?
#
Step
Schema
Code
The 3,604 rows
1
Roll back the code
unit_amount_cents only
v2.30: column "price" does not exist on 40% of requests
still wrong
1 / 5
Answer

Why it fails. v2.30 needs a column that is gone. The down script recreates the column but fills it from the overwritten values, so it makes the corruption permanent in both places, and it costs another 3-minute lock. It is also untested: down scripts almost never run until the worst possible day.

What to do: roll forward. v2.32 is a one-line fix: decide "negotiated" from contract_id IS NOT NULL, not from a column that no longer exists. Deploy it with plan changes still switched off, test it on a canary pod with a handful of real plan changes against staging data, then switch plan changes back on. The data repair is separate work (Break 4).

Prevent: expand / contract. Change a schema in steps, each compatible with the code before it and after it:

DeploySchemaCodeSafe to roll back?
1. ExpandADD COLUMN unit_amount_cents bigint (nullable, instant)v2.31a writes both price and unit_amount_cents, reads priceYes: v2.30 ignores the new column
2. BackfillBatches of 5,000 rows, lock_timeout = '2s', outside any deploysameYes
3. Switch readsCHECK … NOT VALID, VALIDATE, SET NOT NULLv2.31b reads unit_amount_cents, still writes bothYes: back to v2.31a
4. ContractDROP COLUMN price, a week laterv2.31c stops writing priceNo. The one irreversible step, taken deliberately, after the rollback window

Enforce it in CI with a migration linter that rejects a DROP COLUMN or a RENAME in the same release as the code that stops using it, and rejects DDL without a lock_timeout. Ch 8 covers the full method, and PostgreSQL First Principles covers the locks.

The follow-up: "Expand/contract turns a one-deploy change into four. Product says that's too slow. Is it worth it?"

Answer

Only the contract step waits, and it waits in the background: deploys 1–3 can ship the same day, and the drop is a cleanup ticket for next week. The cost is a week of writing two columns and one more small deploy. Compare that with today: 9% of the error budget, 3,604 wrong invoices emailed to customers, $865,000 nearly charged, and an afternoon of senior engineers on a restore. Additive changes (a new column, a new table) need only one step. Expand/contract is for changes that remove or rename. That's the rule to give product: "anything that deletes takes two releases".


Break 4 — Point-in-Time Recovery, for One Table

"Get the 3,604 negotiated prices back. You have PITR. Talk me through it, step by step, with times."

Before you read on:

  1. Postgres PITR restores what: a table, or the whole database cluster?
  2. Which recovery target time do you choose, and why that one?
  3. A customer's price was changed legitimately at 14:20 in the admin tool. Do you overwrite it with the restored value?
#
Time
Production
Restore server
You
1
15:05
serving; plan changes off; charge held for 3,604
provision a new 400 GB server
target: 14:03:00, just before 0142 committed
1 / 9
Answer

PITR restores the cluster, never a table. Postgres replays WAL for the whole database. Restoring production to 14:03 would also throw away everything good written since: about 50 minutes of signups, payments and invoices at 3,000 requests a second. So you restore a copy on a separate server and copy back only what you need.

The steps:

  1. Pick the target. Migration 0142 committed at 14:03:10. That commit dropped price, and the bad writes started after it. A target of 14:03:00 gives a copy that still has the price column with every negotiated price. Write the target down before you start. It is the decision everything else depends on.

  2. Restore the base backup taken before the target (02:00) to a new server.

  3. Configure recovery: restore_command to fetch WAL from the archive, recovery_target_time = '… 14:03:00', recovery_target_action = 'pause', and create an empty recovery.signal file. pause keeps the server read-only at the target, so you can check you stopped in the right place before ending recovery. We ran this on Postgres 18: the log said "starting point-in-time recovery to …", then "recovery stopping before commit of transaction …", and the paused copy held the values from before the bad update.

  4. Export id, price for the 3,604 affected ids.

  5. Reconcile in production, in one transaction, and only where the row still holds the bad value and nobody else has changed it since:

    sql
    UPDATE subscriptions s
    SET unit_amount_cents = round(r.old_price * 100)
    FROM repair_0142 r, plans p
    WHERE s.id = r.id
      AND p.id = s.plan_id
      AND s.unit_amount_cents = p.list_price_cents        -- still the bad value
      AND NOT EXISTS (
        SELECT 1 FROM subscription_audit a
        WHERE a.row_id = s.id
          AND a.changed_at > '2026-10-20 14:03:10+00'
          AND a.app_version <> 'v2.31'
      );
    

    The 37 rows a person changed after 14:03 are not touched. They go to the account managers with both values.

  6. Fix what the data produced. Void the 3,604 wrong invoices and issue corrected ones, rather than editing invoices customers already have (accounting needs the trail), and send one clear correction email. Then release the held charges.

Recovery time. About 4 hours from start to fixed data, against a runbook that says 1. The 18:00 charge had to run before the restore finished, which is why you held only the affected customers and let everyone else's go through.

Make it faster next time:

  • A delayed replica: a replica with recovery_min_apply_delay = '4h' is always 4 hours behind. At 14:51 it was at about 10:51, before the bug. Pause it with pg_wal_replay_pause() and read the old values within minutes, no restore needed. It also catches a DELETE without a WHERE, if you notice within 4 hours.
  • More frequent base backups: every 6 hours means at most 6 hours of WAL to replay.
  • Keep old values in the audit table for money columns: then this repair is a query, not a restore.

The follow-up: "Why not keep a nightly pg_dump and restore the table from that instead of PITR?"

Answer

A logical dump is a good second tool, but it's a snapshot from 02:00. Any negotiated price changed between 02:00 and 14:03 would be restored to its morning value, and you'd need the audit table to find those rows (and the morning value would be wrong for them). Dumps also get slow and heavy as the database grows: a full pg_dump of 400 GB every night competes with production for I/O. PITR gives you any second within the retention window. The dump gives you a second, independent copy in a format you can read without Postgres internals. Have both, and use the dump for the case PITR can't cover: a broken archive. That's Break 5.


Break 5 — The Restore Has Never Been Tested

"Good plan. Now run it for real. This is the first restore anyone has ever done on this system. It's 18:05, three hours in, against a runbook that said one, and the restore server has just stopped with a fatal error. What happened, and what now?"

Before you read on:

  1. List the things that can go wrong in a restore that has never been run. Aim for five.
  2. Postgres can't find a WAL file it needs before the target. What does it do?
  3. How do you still get the 3,604 prices back tonight?
#
Time
Restore step
What happened
What the runbook said
1
15:05
provision a server
15 min
10 min
1 / 10
Answer

What Postgres did. Since version 13, if recovery runs out of WAL before reaching the configured target, Postgres stops with "recovery ended before configured recovery target was reached" instead of quietly opening the database at an earlier point. We reproduced it on Postgres 18 by deleting one archived WAL file from the middle of a backup's archive: replay stopped at the gap with exactly that error. We also reproduced the cause: with archive_command = 'exit 0', pg_stat_archiver.archived_count went up and the file never reached the archive. Postgres trusts the exit code, and only the exit code.

Recover tonight. You can still get a copy as of 09:14. Restart the restore server with no recovery target: it replays to the end of the WAL it has and opens there. (We checked this too: the same server that failed came up as a normal read-write database at the end of the available WAL.) Then:

  • For the 3,571 affected subscriptions that the audit table shows were not changed between 09:14 and 14:03, the 09:14 value is the right one. Restore those.
  • For the 33 that were changed in that window, use last month's invoice, the signed contract in the CRM, and the account managers. That's a few hours of people's time, not a technical problem.

The other ways an untested restore fails (say several, it shows you've done this):

FailureHow you find outThe fix
No permission to read the backupsAt step 1Test break-glass access in every drill
Encryption key missing or unreachableWhen the download is uselessKeys stored where the on-call can reach them, tested in the drill
WAL gap: the archiver lied, or files were deletedHours in, at the gapAn archive command that fails loudly, plus a continuity checker
Restore far slower than the runbookWhen the business asks "how long?"Measure it: the drill's duration is your RTO
The restored copy is missing extensions, roles or configWhen the app can't connectRestore with the same image and configuration as production
Retention shorter than the time to noticeWhen the backup you need has been deletedRetention longer than your slowest detection, at least 14 days

Prevent.

  • An automated restore drill, weekly at least: restore the latest base backup to a scratch server, replay to "now minus one hour", run checks (row counts against production, the newest updated_at close to the target, a few known rows), record how long it took, and throw the server away. Alert if it fails or takes longer than your RTO. Untested backups are a hope, not a plan.
  • An archive command that fails when the upload fails. Postgres then keeps the WAL file and retries. The cost is that pg_wal grows if archiving stays broken, so alert on pg_stat_archiver.failed_count, on last_archived_time older than a few minutes, and on disk usage. Better still, use a maintained tool such as pgBackRest or WAL-G, or your managed service's built-in PITR, instead of a home-grown script.
  • A continuity checker that lists the archive every hour and alerts on any missing WAL file name.
  • State RPO and RTO, and measure them. RPO for PITR is how far behind the archive is: with archive_timeout = 60s, about a minute. RTO is whatever the last drill took, not what the runbook says.

The trade-off: a weekly 400 GB restore costs a server for a few hours a week and some engineering to automate. Today it would have caught the gap at 09:20 instead of 18:05, and would have told you the real RTO was about 4 hours before anyone promised 1.

The follow-up: "Write the postmortem's action items. Which ones actually change the system?"

Answer

An action item changes the system when it removes a way to fail, not when it asks people to be more careful. "Be careful with migrations" changes nothing.

Action itemKindOwner
CI migration linter: no DROP/RENAME in the same release as the code change, lock_timeout requiredPrevents the causePlatform
Correctness SLI on invoices vs contracts, with a burn-rate alertDetects it in minutesBilling
Card charges blocked automatically while that SLI is failingStops the irreversible side effectBilling
Archive script replaced by a maintained tool. Archiver and continuity alertsRemoves the silent gapPlatform
Weekly automated restore drill, RTO published on a dashboardMakes the backup realPlatform
Delayed replica (4 h)Makes the next repair take minutesPlatform
Audit table keeps old values for money columnsMakes the next repair a queryBilling
Auth middleware stops reading the billing table on every requestShrinks the blast radius of the next lockIdentity

Write the timeline without blame. The engineer who wrote sub.price == null made a reasonable choice for the schema they had, and the system let a column vanish under them (Debugging 15).


The Final Design

ChangeMotivated byWhat it costs
lock_timeout on migrations, DDL retried with backoffBreak 1Migrations sometimes take longer
Backfills in batches, outside the DDL transaction; NOT VALID + VALIDATE before SET NOT NULLBreak 1More migration steps
Auth path stops reading the billing tableBreak 1A small refactor
Correctness SLI with a burn-rate alertBreak 2A few scheduled queries
Emails and card charges held automatically while the correctness check failsBreak 2A switch on each side effect
Expand/contract, enforced by a CI linterBreak 3Two releases for anything that deletes
Roll forward by default; down scripts not trustedBreak 3
Side restore and reconcile, never restore production in placeBreak 4A runbook and a spare server
Delayed replica (4 h), base backups every 6 h, old values in the audit tableBreak 4One more server, more backup storage
Maintained archiving tool, archiver and continuity alertsBreak 5Migration off the custom script
Weekly automated restore drill with a measured RTOBreak 5A few server-hours a week
Backups in a second region, retention 14 daysBreak 5Storage
Canary pod with automatic rollback on SLO burnBreak 1A slower rollout

Scoring Yourself

SkillStrongOKWeak
The lockExplains ACCESS EXCLUSIVE held by one transaction, the waiting queue, pool exhaustion on unrelated endpoints; cancels the migration; lock_timeout + batched backfillKnows migrations can lockBlames load or the deploy tool
Old code, new schemaSees that a dropped column breaks pods still on the old version; knows rollback makes it worseBlames the rolling deployRolls back
Error budgetComputes about 9% of the month, and the 800× burn rateSays "a lot of the budget"No numbers
Wrong-data incidentConfirms it's ongoing, stops writes and side effects (the 18:00 charge) before debugging, scopes with the audit tableFinds the bug first, then containsFixes three invoices by hand
RollbackExplains why code rollback and the down script both fail; rolls forward; describes expand/contract with the four steps"We should have had a down migration""Restore from backup"
PITRSide restore, never in place; target chosen relative to the migration commit; reconciles around rows changed since; knows the delayed replicaKnows PITR existsRestores production to 14:00
Backups you can trustNames five ways an untested restore fails, an archive command that fails loudly, automated restore drills, RTO as a measured number"Test the backups"Trusts the runbook's 1 hour

Five or more strong: this round is yours. If PITR or the backup row was weak, read Ch 7 and run a restore yourself on a local Postgres: it takes an evening and changes how you answer for good.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If a migration backfills inside the transaction that took an ACCESS EXCLUSIVE lock
  • If a waiting DDL has no lock_timeout
  • If a release drops a column the old code reads
  • If the only SLOs are availability and latency
  • If information was destroyed (dropped or overwritten)
  • If PITR is done in place
  • If the archive command reports success without checking

What's Next

That's the last drill. Go back to the Course Map, pick the drill you scored worst on, and redo it cold in a week, out loud, with a timer. Then try one on your own system: draw your real production design, and break it the way these drills did.