Walk through changing the representation of an existing column — for example splitting a single full_name column into first_name and last_name — on a busy production table with no downtime. What are the phases, and what must be true of the running application at each one?
answer
- Expand → dual write → backfill → verify → read new → stop writing old → drop
- Additive DDL first, subtractive DDL last
- Both writes in one transaction; same transform rule as the backfill
- Switch reads behind a flag = cheap rollback
- Constraints tightened only after every writer complies
basics
~20 sExpand: add the new columns and make the app write both. Migrate: backfill existing rows in batches, then switch reads to the new columns. Contract: stop writing the old column and drop it. Every step must work with the previous version still running.
solid answer
~60 sThis is **expand-contract** (also called parallel change), and it is a sequence of separate deploys, not one migration. 1. **Expand (schema).** Add `first_name` and `last_name` as nullable. Purely additive: old code neither reads nor writes them. 2. **Dual write.** Deploy application code that writes both representations on every insert and update, but still reads `full_name`. Now new and changed rows are correct in both places. 3. **Backfill.** Populate the new columns for pre-existing rows in bounded batches, throttled, resumable, and idempotent. It must not conflict with dual writes — write it so re-running is harmless. 4. **Verify.** Compare old and new for a sample or, better, continuously; count rows where they disagree. Do not proceed on faith. 5. **Switch reads.** Deploy code that reads the new columns while still writing both. This is the reversible cutover point: a rollback returns to reading `full_name`, which is still being maintained. 6. **Stop writing the old column**, wait past the rollback horizon, then **contract**: drop `full_name`, and tighten constraints such as `NOT NULL` on the new columns. Each deploy is independently revertible, and no deploy breaks the version running beside it.
code
sql · 16 lines-- deploy 1: expand (additive only)
ALTER TABLE person ADD COLUMN first_name TEXT NULL;
ALTER TABLE person ADD COLUMN last_name TEXT NULL;
-- deploy 3: backfill runs in batches, only where not already set
UPDATE person
SET first_name = split_part(full_name, ' ', 1),
last_name = substr(full_name, length(split_part(full_name, ' ', 1)) + 2)
WHERE id > :last_id
AND first_name IS NULL
ORDER BY id
LIMIT 5000;
-- final deploy: contract, then tighten
ALTER TABLE person DROP COLUMN full_name;
ALTER TABLE person ALTER COLUMN last_name SET NOT NULL;go deeper
Name the three phases and say why the old column cannot be dropped in the same release that stops using it.
Give the full ordered sequence with the deploy boundaries, explain dual write in one transaction, and say why constraints are tightened only at the end.
Add operational substance: batched throttled backfill, continuous verification, read switch behind a flag, the rollback horizon before the drop, and how you find writers outside the main service.
Weigh the whole ceremony against its cost — six deploys, doubled writes, temporary complexity — decide when a short maintenance-window lock is the better engineering call, and insist the contract step is tracked so the schema does not stay forked.
## Why one step is impossible The naive plan — `ALTER TABLE`, update the code, ship — assumes a single instant where schema and code change together. Rolling deploys have no such instant: old and new application versions run concurrently against one database, and a rollback puts old code in front of the new schema. Every migration must therefore be decomposed into steps where **both** versions of the application work against **both** shapes of the schema. Expand-contract is that decomposition. The name describes the shape: the schema temporarily grows to hold both representations, the system migrates across, then the schema shrinks again. ## Phase 1 — Expand (additive DDL) Add the new columns nullable, with no constraints that current traffic could violate, and add any indexes they will need (built without blocking writes — see the concurrent/online index options of your engine). This deploy touches only the schema. Old code is oblivious. Do not add `NOT NULL` yet: existing rows have no values, and old code does not supply any. ## Phase 2 — Dual write Deploy application code that, on every write path, populates both the old and the new representation. Reads still come from the old column, so behaviour is unchanged and the change is low-risk. Things to get right here: - **All write paths.** Not just the main service — admin tools, batch jobs, imports, other services with their own connection, and anything writing directly via SQL. A missed path is the usual cause of a mismatch discovered weeks later. Where the engine supports it and the transform is trivial, a trigger or generated column can catch stragglers, at the cost of extra write work. - **One transaction.** Both writes must be in the same transaction, otherwise a crash between them leaves the row inconsistent. - **Deterministic transform.** `full_name` → `(first_name, last_name)` is lossy and ambiguous; decide the rule (last token is the surname? split on the first space? what about a single token?) and use the *same* rule in the application and in the backfill, or the two will disagree. ## Phase 3 — Backfill Only rows that existed before dual write need conversion, but you generally sweep the whole table because you cannot cheaply tell which is which. The backfill runs in bounded batches ordered by the primary key, tracking the last key processed so it can resume; it commits per batch, sleeps or throttles when replication lag or lock waits rise, and is written so that re-running a batch is harmless. It should skip or overwrite consistently with the dual-write rule — the common safe form is "set the new columns only where they are still NULL", which never clobbers a value dual write already produced. ## Phase 4 — Verify before you trust Run a comparison that counts rows where the two representations disagree, and keep running it while dual write continues. On a large table, sample by key range rather than scanning everything at once. This is the step teams skip, and it is the step that catches the write path nobody knew about. ## Phase 5 — Switch reads Deploy code that reads the new columns and still writes both. This is the moment behaviour actually changes, so it is worth putting behind a flag that can be flipped without a deploy: flag on, watch errors and business metrics, flag off if wrong. Because the old column is still being maintained, rolling back is a flag flip or a redeploy, not a data recovery exercise. ## Phase 6 — Contract Once reads have been on the new representation long enough that you will never roll back past it — and long enough that any long-lived process, cached deployment or offline job has cycled — deploy code that stops writing the old column, then drop it. Only now is it safe to tighten: `NOT NULL` on the new columns, check constraints, unique constraints. Adding constraints last is deliberate; you can only assert an invariant once every writer honours it, and validating it is itself a potentially blocking operation that should use the add-then-validate form where the engine offers it. Dropping is not optional bookkeeping. A half-finished expand-contract leaves two representations, dual-write code nobody dares remove, and the next engineer unsure which is authoritative. Track the contract step as work, not as cleanup. ## Ordering and rollback summary Schema-additive first, code that writes both, data migration, code that reads new, code that stops writing old, schema-subtractive last. Between any two consecutive steps the system is consistent and both application versions function; every step can be reverted by redeploying the previous version without touching data. ## Cost to acknowledge Six deploys instead of one, a period of doubled write cost and doubled storage on that column, temporary complexity in the code, and a verification job to build. That is the price of not taking the service down; for a table small enough or a system with a maintenance window, a single locked migration is a legitimate and much cheaper choice. Knowing when the ceremony is *not* warranted is part of the answer.
- At which step is the change no longer cheaply reversible, and how do you make that step as safe as possible?Reversal stays cheap right up to the moment you stop writing the old column, because until then the old representation is still current and rolling back is just redeploying. The genuinely risky transition is the read switch, so it belongs behind a runtime flag rather than a deploy, ideally rolled out to a fraction of traffic while error rates and business metrics are compared. Dropping the column is the point of no return and should sit well past the rollback horizon.
- How do you make sure no write path is missed during the dual-write phase?Inventory every writer, not just the primary service: batch jobs, admin consoles, data imports, other services sharing the database, and ad-hoc SQL. Then verify empirically with a continuously running comparison query that counts disagreements, because the inventory is always optimistic. Where the transform is simple and the engine supports it, a trigger or a generated column removes the question entirely at the cost of extra work on every write.
- When would you skip expand-contract and just take a short lock?When the table is small enough that the rewrite finishes in well under the timeout budget, or when the system has a real maintenance window and the operational simplicity is worth more than the uptime. Expand-contract costs six deploys, doubled writes and a verification job, so on a hundred-thousand-row configuration table it is pure ceremony. The judgement is about table size, traffic and the cost of an outage, not about principle.
It is how a city replaces a bridge without closing the river crossing: build the new span beside the old one, run traffic over both, move traffic across, and only then demolish the original.
saying these in an interview costs you the question
- Backfilling in one statement across the whole table instead of in batches
- Dropping the old column in the same deploy that switches reads
- Adding NOT NULL to the new column before every writer populates it
- Using a different transform rule in the backfill than in the application's dual write
- Assuming the service is the only writer and skipping the verification step
- Leaving the contract phase permanently undone so both representations linger