Chapter 8 — Observability and Recovery
See the failure fast, undo it safely, change the schema without stopping traffic, and practise all of it before it happens
Prerequisites: SD 2 Ch 18 — Observability (metrics, traces, logs, SLOs, burn-rate alerts, runbooks), Debugging 10 — Production observability (p50 vs p99, RED and USE, alert to root cause), Debugging 15 — Incidents and postmortems (mitigation levers, why rollback is not always safe, postmortems), PostgreSQL From First Principles, Ch 18 (migration lock costs in outline), SD 1 — Load balancing (rolling, blue-green and canary deploys).
How to read this chapter
Observability, SLOs, rollbacks and postmortems are covered in depth in the chapters above, so Parts 1, 2 and 7 are summaries: what you must be able to say in the room, with links. The rest of the chapter is about two things the reader is thin on:
- Zero-downtime schema changes (Parts 3 to 6): expand/contract, the order of deploys, which Postgres DDL blocks traffic and for how long, and backfills that don't lock the table.
- Fault injection, chaos testing and game days (Part 8): breaking your own system on purpose, safely, so that the interviewer's "what if X dies?" has an answer you have measured.
Every Postgres number was measured on PostgreSQL 18.6 on a laptop, against a 5-million-row table (510 MB). The Node code was run on Node 22 with pg 8.22. Absolute times scale with table size and hardware. Which statements block what does not.
| Term | Meaning |
|---|---|
| SLI / SLO | A measured ratio of good events (SLI) and its target over a window (SLO): "99.9% of checkouts succeed in under 1 s, over 30 days" |
| Error budget | What the SLO allows to fail. 99.9% over 30 days is 43.2 minutes of full outage |
| Burn rate | How fast the budget is being spent, as a multiple of the even pace. 14.4× spends 2% of a 30-day budget in one hour |
| Rollback / roll forward | Return to the previous version, or fix by shipping a new one |
| Expand/contract | Change a schema in compatible steps: add the new thing, move to it, remove the old thing last |
| Backfill | Filling a new column or table from existing data, in small committed batches |
| Dual write | Writing both the old and the new place during a transition, so either can be read |
| Lock queue | Postgres grants table locks in arrival order. A waiting lock blocks every later request that conflicts with it |
lock_timeout | How long a statement may wait for a lock before failing with error 55P03 |
NOT VALID / VALIDATE | Add a constraint without checking old rows (instant), then check them later under a weaker lock |
| Fault injection | Deliberately causing a specific failure: latency, errors, a killed process, a full disk |
| Chaos experiment | A fault injection run as an experiment: a hypothesis, a limited blast radius, abort conditions |
| Game day | A scheduled exercise where a team practises a failure, including the people, alerts and runbooks |
Part 1: Observability in the room
What you must be able to say (Debugging 10 and SD 2 Ch 18 have the detail):
- Watch p50 and p99 together. p50 rising means everyone is slower: look for a shared cause. p99 rising alone means some requests are slower: break it down by endpoint, tenant, host and version. You can't average percentiles across servers. And tails multiply: a page that waits on 10 services, each slow 1% of the time, is slow on 1 − 0.99¹⁰ ≈ 9.6% of loads.
- SLIs measure what users experience. SLOs set the target. The error budget is what's left to spend. For 99.9% over 30 days, that's 43.2 minutes, or 0.1% of requests.
- Alert on burn rate, not on thresholds. A 14.4× burn over the last hour (2% of the monthly budget gone in 60 minutes) pages someone now. A 1× to 2× burn over 6 hours opens a ticket. Cause metrics such as CPU and memory are dashboard clues, not pages.
- Saturation is the early warning: pool wait count, queue depth, event-loop delay. They rise minutes before errors do.
- Every alert maps to a first runbook step. If the on-call engineer's first move isn't written down, the alert is noise.
| Alert | First runbook step |
|---|---|
| Checkout error budget burning at 14.4× | Is it one version? Check the deploy marker and errors by version, and roll back if so (Debugging 15) |
| DB pool wait count > 0 for 2 min | pg_stat_activity: what are the active connections waiting on? Look for a lock or one slow query |
| Queue age > 5 min | Are consumers alive? Is one message failing and being retried forever? |
pg_stat_archiver.failed_count increasing | Fix the archive before pg_wal fills the disk (Chapter 7) |
| No metrics from service X for 2 min | Dead man's switch: is X down, or is its telemetry broken? |
Part 2: Rollbacks and feature flags
- Most incidents start with a change. The fastest mitigation is usually to undo the change: roll back the deploy, or turn off the flag. Reading the diff comes later.
- A flag is faster than a rollback. A flag flip takes seconds and doesn't need a build. It only helps if the broken code is behind that flag. The kill-switch design, including what the flag defaults to when the flag service itself is down, is in Chapter 6, Part 4.
- Canary first. Send 1–5% of traffic to the new version, compare its error rate and p99 with the old version's, and promote it only if they match (SD 1 — Load balancing). A bug that hits 1% of requests on a 5% canary fails 0.05% of total traffic.
- A rollback is only safe if the old code works against the current schema and data. If the release dropped a column, renamed one, or wrote rows in a format the old code can't read, rolling back the code makes things worse. That is the whole reason for Parts 3 to 6.
Part 3: Why schema changes break running systems
A schema change is hard to do safely because three things are true at once:
- Two versions of the code run at the same time. A rolling deploy of 6 pods runs v1 and v2 side by side for minutes, and a rollback does the same in reverse. The schema at any moment has to work for both.
- The migration and the code ship separately. The migration runs once, at some moment, and the pods change over time. Whichever runs first, the other one has to cope.
- DDL takes locks, and in Postgres a lock that waits can stop traffic even when the DDL itself would take 0.3 ms (Part 5).
Here is what happens when you ignore rule 1. The release renames a column in a migration that runs before the pods are replaced:
RENAME COLUMN name TO full_name (0.4 ms)users.name → full_nameExpand/contract
The fix is to split one breaking change into several compatible ones. Renaming users.name to users.full_name:
| Step | Schema | Code | Safe to roll back to the previous step? |
|---|---|---|---|
| 1. Expand | ADD COLUMN full_name text (nullable, instant) | v1 unchanged | ✓ v1 ignores the new column |
| 2. Dual write | v2: writes name and full_name, reads name | ✓ v1 still works: name is still correct | |
| 3. Backfill | Copy name to full_name for old rows, in batches (Part 6) | v2 | ✓ |
| 4. Verify | SELECT count(*) WHERE full_name IS DISTINCT FROM name returns 0 | v2 | ✓ |
| 5. Switch reads | Optionally add NOT NULL safely (Part 5) | v3: reads full_name, still writes both | ✓ back to v2: name is still being written |
| 6. Stop old writes | v4: writes only full_name | ✓ back to v3, as long as nothing reads name | |
| 7. Contract | DROP COLUMN name, after a soak period | v4 | ✗ the only one-way step, done when nothing has used name for days |
Notice that every step except the last can be undone by a normal rollback. Notice also that a "rename" became four deploys and two migrations. That's the price. Smaller teams sometimes merge steps 2 and 5 or 5 and 6. What you can never merge is the expand step with the contract step.
The same pattern covers changing a column's type (add the new column, dual write, backfill, switch, drop), splitting a table, and moving data to a new store.
Dual writes: in one database, and across two
Inside one Postgres database, the dual write happens in the same transaction as the original write, so the two columns can't drift apart. A trigger can do the copy instead of the app (BEFORE INSERT OR UPDATE … SET NEW.full_name = NEW.name). This works even for writers you forgot about, such as admin scripts and other services, at the cost of hidden logic that must be removed in the contract step.
Across two systems (Postgres to a new search index, or an old service's database to a new one), a dual write from the app is not atomic. The first write succeeds, the process crashes, the second never happens, and the stores disagree with nothing recording the gap. Use the outbox pattern or change data capture instead: write once, and let a reliable consumer copy the change (Q49 — Outbox). Then run a reconciliation job that compares the stores and reports differences, before you switch reads.
Part 4: The order of deploys
Four rules turn expand/contract into a release plan:
- Schema before code that needs it. The expand migration ships and completes before any pod that writes the new column starts.
- Code stops using something before the schema removes it. The contract migration ships in a later release than the code change that stopped reading and writing the old column, after that release has soaked.
- Every release must work with the schema of the release before it and the one after it. That's what makes both the rolling deploy and the rollback safe.
- Migrations run as their own step, once, not on every pod's boot. Six pods booting at once means six runners racing. Most migration tools take a lock so that only one proceeds, but a separate pipeline step also gives you one place to watch, time and stop the migration.
One more trap at the contract step: code you don't think of as reading the column. ORMs that SELECT * into a model with a fixed field list, reports and analytics jobs, other services sharing the database, and prepared statements that can fail with cached plan must not change result type after a column is dropped. Search query logs (pg_stat_statements) for the column name before you drop it.
Part 5: Lock-safe Postgres DDL, measured
Almost every ALTER TABLE takes the strongest lock
The surprising result first: adding a nullable column takes an ACCESS EXCLUSIVE lock, the same mode as a full table rewrite. It blocks every read and every write on the table while it is held. The safe forms are safe because they hold that lock for well under a millisecond, not because they take a weaker lock. (The PostgreSQL chapter's table lists ADD COLUMN under AccessShareLock. The run below shows it is AccessExclusiveLock, held briefly.)
Method: each DDL ran inside BEGIN … ROLLBACK on a 5-million-row, 510 MB table and held its lock for 2 s. pg_locks showed the mode. A second session tried a primary-key SELECT and an INSERT, each with lock_timeout = '300ms'. The time is how long the DDL itself took, which is how long an autocommitted DDL would hold the lock.
| DDL | Lock taken | Reads | Writes | Time on 5M rows | Safe? |
|---|---|---|---|---|---|
ADD COLUMN c text | ACCESS EXCLUSIVE | blocked | blocked | 0.3 ms | ✓ with lock_timeout |
ADD COLUMN d int NOT NULL DEFAULT 0 | ACCESS EXCLUSIVE | blocked | blocked | 0.7 ms | ✓ constant default stored in the catalog (Postgres 11+) |
ADD COLUMN e timestamptz DEFAULT clock_timestamp() | ACCESS EXCLUSIVE | blocked | blocked | 5.0 s | ✗ volatile default: every row rewritten |
ALTER COLUMN a TYPE bigint (from int) | ACCESS EXCLUSIVE | blocked | blocked | 7.3 s | ✗ rewrite: use a new column + backfill |
ALTER COLUMN a SET NOT NULL | ACCESS EXCLUSIVE | blocked | blocked | 116 ms | ✗ full scan under the lock: grows with table size |
ADD CONSTRAINT … CHECK (a IS NOT NULL) | ACCESS EXCLUSIVE | blocked | blocked | 113 ms | ✗ full scan under the lock |
… the same with NOT VALID | ACCESS EXCLUSIVE | blocked | blocked | 0.6 ms | ✓ old rows not checked |
VALIDATE CONSTRAINT (the check) | SHARE UPDATE EXCLUSIVE | ok | ok | 103 ms | ✓ scans without blocking writes |
CREATE INDEX | SHARE | ok | blocked | 1.0 s | ✗ writes wait for the whole build |
CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | ok | ok | 1.5 s | ✓ slower, can't run in a transaction |
ADD FOREIGN KEY (pid) REFERENCES parent | SHARE ROW EXCLUSIVE | ok | blocked | 261 ms | ✗ writes blocked while every row is checked |
… the same with NOT VALID | SHARE ROW EXCLUSIVE | ok | blocked | 0.9 ms | ✓ brief |
VALIDATE CONSTRAINT (the foreign key) | SHARE UPDATE EXCLUSIVE | ok | ok | 259 ms | ✓ |
RENAME COLUMN b TO body | ACCESS EXCLUSIVE | blocked | blocked | 0.4 ms | ✓ for the database, ✗ for running code (Part 3) |
DROP COLUMN b | ACCESS EXCLUSIVE | blocked | blocked | 0.5 ms | ✓ only as the contract step |
The full scans took about 100 ms here because the 510 MB table was in memory. On a 500 GB table read from disk, the same SET NOT NULL holds ACCESS EXCLUSIVE for minutes.
Recipes
Add NOT NULL to an existing column without a long lock (Postgres 12 and later):
SET lock_timeout = '2s';
ALTER TABLE t ADD CONSTRAINT a_not_null CHECK (a IS NOT NULL) NOT VALID; -- 0.6 ms
ALTER TABLE t VALIDATE CONSTRAINT a_not_null; -- 103 ms, reads and writes keep running
ALTER TABLE t ALTER COLUMN a SET NOT NULL; -- 0.4 ms: no scan, the check proves it
ALTER TABLE t DROP CONSTRAINT a_not_null; -- optional, now redundant
With the valid check in place, SET NOT NULL dropped from 116 ms to 0.4 ms, and at client_min_messages = debug1 Postgres says why: "existing constraints on column "t.a" are sufficient to prove that it does not contain nulls". Postgres 18 also accepts ADD CONSTRAINT … NOT NULL a NOT VALID directly. That was tested too, but only works on 18.
Add an index: CREATE INDEX CONCURRENTLY. It builds in several passes and waits for transactions that started before it, so a long-running transaction delays it, though it never blocks other sessions. If it fails or is cancelled, it leaves an invalid index behind. Cancelled with statement_timeout, the test left t_b_idx with indisvalid = false. The invalid index still costs time on every write and is not used by queries. Drop it with DROP INDEX CONCURRENTLY and try again.
Add a foreign key: ADD CONSTRAINT … NOT VALID, then VALIDATE CONSTRAINT in a separate statement.
Change a column's type: don't use ALTER COLUMN TYPE on a big table. Add a new column, dual write, backfill, switch, drop the old one (Part 3). A few changes don't rewrite the table, such as increasing a varchar(n) limit or changing varchar to text. Test on a copy if you're not sure.
The lock queue: why an instant DDL can stop your API
The table above says ADD COLUMN takes 0.3 ms. Here it is taking most of 5 seconds of traffic with it:
BEGIN, reads t, keeps the transaction open for 6 sThe holder doesn't have to be a report. Anything that has touched the table inside an open transaction counts: a slow analytics query, a connection left idle in transaction, a long backfill, an autovacuum running to prevent wraparound (that one doesn't step aside for you). So migrations always run with a lock_timeout and a retry loop, and teams also set idle_in_transaction_session_timeout so a forgotten transaction can't hold a lock for hours:
import { setTimeout as sleep } from 'node:timers/promises';
// Run one DDL statement without letting it sit in the lock queue.
// 55P03 = lock_not_available, the error lock_timeout raises.
export async function runDdl(pool, sql, { lockTimeout = '2s', attempts = 10 } = {}) {
for (let attempt = 1; ; attempt++) {
const client = await pool.connect();
try {
await client.query(`SET lock_timeout = '${lockTimeout}'`);
await client.query(sql);
return attempt;
} catch (err) {
if (err.code !== '55P03' || attempt === attempts) throw err;
const wait = Math.min(30_000, 250 * 2 ** attempt) * (0.5 + Math.random() / 2);
console.warn(`attempt ${attempt}: no lock within ${lockTimeout}, retry in ${Math.round(wait)} ms`);
await sleep(wait);
} finally {
await client.query('RESET lock_timeout').catch(() => {});
client.release();
}
}
}
Tested with a transaction holding the table for 5 s and lockTimeout: '1s': attempts 1 to 3 timed out, the holder committed at 5.0 s, and attempt 4 succeeded at 5.4 s. Each attempt could hold up other queries on the table for at most the 1 s lock_timeout.
Part 6: Backfills in batches
A backfill fills the new column for existing rows. The tempting version is one statement:
UPDATE t SET region = 'eu'; -- 5,000,000 rows
Measured on the 5-million-row table, it ran for 22 to 33 s. While it ran:
- Every row it had touched stayed locked until it committed. A single-row
UPDATEfrom another session, withlock_timeout = '300ms', was blocked on rows the big update had reached. In an app, that's every write to those rows waiting up to 30 s. - It wrote 1.7 GB of WAL in one transaction, which replicas must receive and replay, so replica lag grows by the whole amount.
- The table grew from 510 MB to 1,020 MB, because MVCC keeps the old version of every row until vacuum can remove it, and it can't while the transaction is open.
- If it fails at row 4,900,000, all of it rolls back.
The batched version commits every 50,000 rows by primary key range. Each batch took about 150 ms. The concurrent single-row updates all succeeded. The whole backfill took 41.7 s, almost twice as long. That's the trade-off: batches are slower in total, but each one holds its locks for a fraction of a second, they can be paused and resumed, and replicas can keep up between them.
import { setTimeout as sleep } from 'node:timers/promises';
// Copy name -> full_name in small committed batches, walking the primary key.
// Safe to stop and rerun: rows already copied are skipped.
export async function backfill(pool, { batch = 5_000, pauseMs = 20, maxLagBytes = 64 * 2 ** 20 } = {}) {
let lastId = 0;
let updated = 0;
for (;;) {
const { rows } = await pool.query(
`WITH b AS (SELECT id FROM users WHERE id > $1 ORDER BY id LIMIT $2),
u AS (UPDATE users SET full_name = users.name
FROM b WHERE users.id = b.id AND users.full_name IS NULL
RETURNING 1)
SELECT (SELECT max(id) FROM b) AS last_id, (SELECT count(*) FROM u)::int AS n`,
[lastId, batch],
);
if (rows[0].last_id === null) return updated;
lastId = rows[0].last_id;
updated += rows[0].n;
// Throttle: wait while any replica is far behind.
for (;;) {
const lag = await pool.query(
`SELECT coalesce(max(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)), 0)::bigint AS bytes
FROM pg_stat_replication`,
);
if (Number(lag.rows[0].bytes) < maxLagBytes) break;
await sleep(1_000);
}
await sleep(pauseMs);
}
}
What makes it safe, line by line:
- Keyset batches (
id > $1 ORDER BY id LIMIT $2) use the primary key index, so every batch costs the same.OFFSETwould get slower with every batch. full_name IS NULLmakes it idempotent. Rerun it after a crash and it skips the rows already done. Rows that new code has dual-written are skipped too.- Each batch is its own transaction, so locks are held for one batch only, and vacuum can clean up between batches.
- The lag check pauses when any replica is more than 64 MB behind. Without it, the backfill can push read replicas seconds behind and users start seeing stale data (Chapter 5).
Tested on 1 million rows: 24.0 s with 5,000-row batches and a 20 ms pause, a rerun updated 0 rows, and no full_name was left empty.
Part 7: Postmortems that change the system
Debugging 15 covers running the incident and writing the postmortem. In a design round you only need the principle: a postmortem is measured by what it changes in the system, not by what it says about people. Action items that work change a mechanism: a lock_timeout added to the migration runner, an alert on archive failures, a row limit on the cleanup job, a restore drill on the calendar. Action items such as "be more careful with migrations" change nothing. Each item has an owner and a due date, and the team tracks them to done. The best items can be tested, which leads to Part 8.
Part 8: Fault injection, chaos testing and game days
Every chapter in this section ends with "when the interviewer breaks it". In production, nobody tells you in advance. Fault injection is breaking it yourself, on purpose, at a time you choose, while people are watching. It turns "we have failover" into "failover took 41 s and lost 0 writes last Tuesday".
| Practice | What it is | Answers the question |
|---|---|---|
| Fault injection | Cause one specific failure: add latency, return errors, kill a process, drop packets, fill a disk | "What does the system do when X fails?" |
| Chaos experiment | A fault injection with a written hypothesis, a limited blast radius and abort conditions, often in production | "Does the system really behave the way we designed it?" |
| Game day | A scheduled team exercise built around one or more injected failures | "Do the alerts fire, do the runbooks work, and do people know what to do?" |
Where you can inject faults
| Layer | How | Good for |
|---|---|---|
| In the process | A wrapper around a client that adds delay or throws, switched on by a flag | Timeouts, fallbacks, circuit breakers, in tests and in production on a small percentage |
| The network | A TCP proxy between the app and a dependency that adds latency, cuts connections or drops packets (Toxiproxy is a common one), or tc netem on Linux | Connect hangs, slow responses, half-open connections |
| The service mesh | Proxies such as Envoy can inject delays and aborts into a share of requests by route | Latency and error experiments without code changes |
| Infrastructure | Kill instances or pods, stop a database primary, block a zone. Netflix's Chaos Monkey made random instance termination famous. Cloud providers now offer managed fault-injection services | Failover, autoscaling, the loss of a zone |
| Data and operations | Restore a backup, break archive_command, expire a certificate, change the clock | The slow failures from Chapter 7 |
The in-process version is a few lines of code and is the easiest place to start:
import { setTimeout as sleep } from 'node:timers/promises';
// Faults are read on every call, so a flag or admin endpoint can change them live.
// Shape: { recommendations: { latencyMs: 2000, errorRate: 0.5, percent: 10 } }
let faults = {};
export const setFaults = (next) => { faults = next; };
export function withFaults(name, fn) {
return async function (input, { signal } = {}) {
const f = faults[name];
// percent limits the blast radius: only that share of calls is touched
if (f && Math.random() * 100 < (f.percent ?? 100)) {
if (f.latencyMs) await sleep(f.latencyMs, undefined, { signal });
if (Math.random() < (f.errorRate ?? 0)) {
throw Object.assign(new Error(`injected fault in ${name}`), { code: 'EINJECTED' });
}
}
return fn(input, { signal });
};
}
With { latencyMs: 2000, percent: 10 } on a recommendations call that the home feed wraps in a 300 ms timeout and an empty-list fallback, 1,000 concurrent feed requests all finished in 317 ms, and 105 of them rendered without recommendations. That's the experiment's result: the 300 ms timeout and fallback held. With { errorRate: 0.5 }, 481 of 1,000 fell back. Because the injected delay takes the caller's signal, the timeout cancels the sleep instead of leaving it running.
Running a chaos experiment
- State the steady state as a metric. "Checkout success rate ≥ 99.5% and p99 < 800 ms, measured each minute."
- Write the hypothesis with numbers. "If the Redis primary is killed, Sentinel promotes a replica within 30 s, and the steady state holds, because product pages fall back to Postgres."
- Limit the blast radius. Staging first. Then production during working hours, on one cell, one zone or 1% of traffic, never everything at once.
- Set abort conditions before you start. "If checkout success drops below 98% for 60 s, stop the experiment," and know exactly how to stop it: a flag, a command, a person with their hand on it.
- Run it, watch it, and write down what really happened, including what you didn't predict.
- Turn every surprise into an action item, and run the experiment again once it's fixed.
Don't run chaos experiments on things you already know are broken. If there's no timeout on the payment call, the experiment teaches you nothing you don't know, and it hurts users. Fix the known gaps, then test that the fixes work.
Try it in the sim
The load-balancer sim works like a small chaos experiment. In the Server dies tab, server C crashes at 2.2 s while health checks probe every 0.5 s and eject a server after 3 failed probes. Predict what clients see, then run it and compare with the "Failed · no checks" count, the same run without health checks. Then switch to the Slow server tab: B becomes 5× slower but stays up and keeps passing health checks. That's the fault your health checks don't catch, which is why latency injection belongs in every experiment plan. In free play you can click any server to cycle it between healthy, slow and dead.
A game day, step by step
A game day tests the people and the process as well as the system. The roles: a facilitator who injects the failure and knows the scenario, responders who use only the real alerts, dashboards and runbooks, and an observer who writes down times. Here is a failover game day in staging, under a load test:
10:00Each finding becomes an action item: a faster burn-rate alert for writes, an updated runbook, and pool settings that drop connections on errors and re-resolve DNS (Chapter 5 covers the failover side). Then the team runs the same game day again. Repeating it is how you find out whether the fixes worked.
What to put on the list
Map each chapter of this section to at least one experiment:
| Experiment | Tests | Measure |
|---|---|---|
| A downstream takes 10 s | Timeouts, deadlines (Ch 1) | In-flight count, pool wait, p99 of callers |
| A downstream fails 50% of calls | Retry budgets, circuit breakers (Ch 2, Ch 3) | Retry rate, time for the breaker to open and close |
| One tenant sends 100× its normal load | Quotas, bulkheads (Ch 3) | Other tenants' p99 |
| Double the traffic in 1 minute | Load shedding, rate limits (Ch 4) | Goodput, which requests were shed |
| Kill the database primary | Failover (Ch 5) | Write error window, writes lost, time to page |
| Flush the cache | Stampede protection, fallbacks (Ch 6) | Database QPS, p99 during warm-up |
| Restore yesterday's backup to a new server | Backups (Ch 7) | Restore time vs RTO, row-count match |
| Run a migration while a transaction holds the table | Lock-safe DDL (Part 5) | API p99 during the migration |
When the Interviewer Breaks It
Try each one out loud before opening the answer.
Break 1: "Rename users.name to full_name. 200 million rows, no downtime."
Answer
30-second answer: "I wouldn't use RENAME COLUMN. It's instant in the database, but every running pod on the old code breaks at once, and rolling back doesn't fix it. I'd expand and contract. Add full_name as a nullable column, which is a catalog change, with a lock_timeout so it can't queue traffic behind it. Deploy code that writes both columns and still reads name. Backfill in batches of a few thousand rows by primary key, idempotent, throttled on replica lag. 200 million rows at about 40,000 rows a second is roughly an hour and a half. Verify there are no mismatches, deploy code that reads full_name, then code that writes only full_name. Drop name days later, once nothing has read it. Every step except the drop can be rolled back."
The follow-up they push on: "Other services read that table directly." Then the contract step waits until they've moved too, and I'd check pg_stat_statements for queries that still mention name before dropping it. Or keep a view with the old column name for them during the transition.
A weak answer sounds like: "Run the migration at 3 a.m. when traffic is low." Low traffic doesn't make old code understand a renamed column, and it doesn't stop a long transaction from queueing the lock.
Break 2: "Your migration only added a nullable column. Why did the API time out for a minute?"
ALTER TABLE orders ADD COLUMN gift_note text). From 14:02 to 14:03 every request touching orders timed out. Database CPU was 8%.Answer
30-second answer: "Adding a nullable column is instant, but it still needs an ACCESS EXCLUSIVE lock, and it has to wait for it. A long-running transaction had already read the table, so the ALTER queued behind it. Postgres grants locks in order, so every normal query that arrived after the ALTER queued behind it too. One open transaction plus one instant DDL stopped the table until the transaction finished. In a test, a primary-key read waited 4.9 s behind a 0.3 ms ADD COLUMN. The fix is lock_timeout on every migration, a couple of seconds, with retries and backoff, plus timeouts that stop transactions from staying open for minutes."
The follow-up they push on: "Why not just kill the long transaction?" You can, with pg_terminate_backend, and some migration runners do so automatically after a grace period. But that transaction might be a payment, or a backfill that rolls back 20 minutes of work. lock_timeout puts the cost on the migration, which is the one thing that's always safe to retry.
A weak answer sounds like: "Adding columns rewrites the table." It doesn't since Postgres 11 for constant defaults, and never did for nullable columns. The candidate is guessing at the mechanism.
Break 3: "The deploy is bad. Roll back. Oh, and the migration dropped a column."
Answer
30-second answer: "Then a rollback of the code won't work: the old code reads a column that isn't there. First I'd stop the bleeding another way: turn off the new feature's flag if it has one, or ship a hotfix forward that doesn't need the column. If data was lost with the column, it comes back with PITR to a side server, from just before the migration, ideally from a restore point the pipeline created, and I copy the column's values back. The design lesson is that a drop must never be in the same release as the code change. It's the contract step, in its own later release, after nothing has used the column for days. Then any release can be rolled back, because the schema always works for one version back."
The follow-up they push on: "How do you enforce that?" A check in CI that fails a migration containing DROP COLUMN, RENAME or ALTER … TYPE unless it's marked as a contract step and references the earlier release that stopped using the column. Also a test that runs the previous release's test suite against the new schema.
A weak answer sounds like: "Roll back the migration too." The DOWN migration can add the column back, empty. The data is gone.
Break 4: "How do you know your failover actually works?"
Answer
30-second answer: "Because we run it. I'd hold a failover game day: in staging under a realistic load test first, then in production during working hours, after telling support. State the hypothesis with numbers, like 'under 60 seconds of failed writes, zero lost writes, paged within 2 minutes', and the abort condition. Then stop the primary for real, not with a graceful switchover. Measure detection, promotion, how long clients kept connecting to the old primary, and when someone was paged. The first time, it usually takes several times longer than the hypothesis: an alert window that's too long, a runbook link that's broken, a pool that holds connections to the old IP. Each one becomes a fix, and we run it again until the measurement matches the design. After that, it's on the calendar quarterly."
The follow-up they push on: "Isn't killing production dangerous?" Less dangerous than finding out during a real outage at 3 a.m. You limit the blast radius (one cell or zone), set abort conditions in advance, have the person who can stop it watching, and do it when everyone's awake. And you don't run it until staging passes.
A weak answer sounds like: "Our cloud provider handles failover." The provider promotes a replica. Your connection pools, DNS caching, alerts and runbooks are yours, and that's where game days find problems.
Break 5: "What do you alert on, and what does on-call do when it fires at 3 a.m.?"
Answer
30-second answer: "I'd alert on user symptoms against an SLO: for checkout, 99.9% succeed in under a second over 30 days. A fast burn, 14.4 times the even pace over an hour, which is 2% of the month's budget, pages. A slow burn over six hours opens a ticket. Saturation signals like pool wait count and queue age are early warnings on a dashboard, not pages. Each page links to a runbook whose first step is 'what changed?': the deploy markers and the error rate by version. If it's one version, roll back or turn off the flag first and investigate after. If it's every version, check the shared dependencies: the database, the cache, the payment provider."
The follow-up they push on: "How do you know the alerting itself works?" A dead man's switch alert on missing metrics, and game days where the observer records when the page arrived. If the page came 4 minutes after the failure in a game day, the window is too long.
A weak answer sounds like: "Alert when CPU is over 80%." CPU at 80% with happy users is fine. Users failing at 30% CPU is not.
The Causal Chains
- If a migration renames or drops a column in the same release as the code change
- If DDL waits for its lock with no
lock_timeout - If you add
NOT NULLor aCHECKdirectly - If you build an index without
CONCURRENTLY - If you backfill with one big
UPDATE - If you dual write across two systems from the app
- If failover has never been tested under load
- If you inject a fault with no abort condition and no limited blast radius
Checklist: add this to any design
- SLOs per user journey, with burn-rate alerts that page and a runbook whose first step is "what changed?".
- Deploy markers, canaries and feature flags, so a bad release is seen in minutes and undone in seconds.
- Expand/contract for every schema change: no
RENAME,DROPor type change in the same release as the code that depends on it. - One migration step in the pipeline, run once, with
lock_timeout(2–5 s) and retries, and a restore point created before it. - Lock-safe forms:
CREATE INDEX CONCURRENTLY, constraintsNOT VALIDthenVALIDATE,SET NOT NULLthrough a validated check, new column + backfill instead ofALTER COLUMN TYPE. - Batched, idempotent, throttled backfills, by primary key, with a verification query before reads switch.
- Timeouts that stop forgotten transactions:
idle_in_transaction_session_timeout, andstatement_timeoutfor reporting roles. - A fault-injection hook in each client (latency, errors, percentage), off by default, so experiments don't need a deploy.
- A game day calendar: failover, cache loss, slow downstream, backup restore, each with a numeric hypothesis and abort conditions, rerun after fixes.
What's next
That's the last topic chapter. The drills put them together under pressure: a design is drawn, then broken step by step. Start with Drill 9 — The Cache Dies. Drill 14 — The Bad Deploy uses this chapter and Chapter 7 most: a bad migration, a rollback that won't work, and a restore that has never been tested.