skip to content

A release ships application code together with a database migration that renames a column, and the release turns out to be bad. Why can you no longer simply redeploy the previous version, and how should that migration have been sequenced so the code stays rollback-safe?

level: seniorimportance: must knowfreq 62%

answer

  1. schema is shared state, code is not
  2. down migrations cannot restore data
  3. every schema state must serve N-1
  4. expand, dual-write, backfill, switch, contract
  5. the drop is the point of no return

basics

~20 s

A renamed column leaves rolled-back code reading a column that no longer exists, and the reverse migration destroys data. Sequence it expand-contract: add the new column, dual-write, backfill, switch reads, and drop the old one only after the rollback window closes.

solid answer

~50 s

Coupling a destructive schema change to a code change makes the deploy one-way. The old binary was written against the old column; once the rename has run, redeploying it produces errors on every query touching that column. Running the down migration is worse, not better - re-creating a column does not bring back its contents, every row written since the release is lost, and the DDL itself can lock a large live table while you are already in an incident. The discipline that fixes this is expand-contract, sometimes called parallel change: expand the schema additively, deploy code that writes both the old and new shapes, backfill in batches, deploy code that reads the new shape, and only then contract by dropping the old column. Every step is separately deployable and compatible with the version on either side of it, so any single deploy can be rolled back. The cost is more deploys spread over days, which is exactly why teams skip it.

code

sql · 11 lines
sql
-- Step 1: expand - additive only, the running code is unaffected
ALTER TABLE users ADD COLUMN email_address text;

-- Step 3: backfill in bounded, resumable batches
UPDATE users
   SET email_address = email
 WHERE email_address IS NULL
   AND id >= 1 AND id < 10000;

-- Step 6: contract - only once you will not roll back past step 5
ALTER TABLE users DROP COLUMN email;

go deeper

for a junior

Know that a database migration does not undo itself when you redeploy older code, and that a dropped column takes its data with it.

for a middle

Walk the expand-contract sequence in order and explain why each step is compatible with the version deployed before it, including why a NOT NULL column with no default breaks the previous release.

for a senior

Show the production judgment: refuse to couple destructive DDL to a code deploy, size and throttle the backfill, and set an explicit rollback window that decides when the contract step runs.

for a principal

Own it as policy - migrations reviewed against an N-1 compatibility rule, the calendar cost of parallel change accepted as the price of reversibility, and a clear position on which datasets are large enough that the DDL itself needs its own plan.

## Why schema changes are what actually blocks rollback Most rollbacks are trivial: point production at the previous artifact. Schema and data are what turn that into an outage, because the database is shared mutable state that both versions of the code must be able to use. A code deploy is reversible; a `DROP COLUMN` is not. When a release runs `ALTER TABLE ... RENAME COLUMN` and ships code that uses the new name, the two are welded together. Rolling back the code alone leaves old code against a new schema - every statement referencing the old name fails. There is now no version of your code that works with the database as it currently stands except the bad one. ## Why the "down migration" is not the answer Migration tools let you write a reverse step, and that creates a false sense of safety: - **It cannot restore data.** Reversing a `DROP COLUMN` re-creates an empty column. Everything that was in it is gone unless you separately preserved it. - **It loses the writes since.** Rows created or updated while the new version was live wrote to the new shape. Reversing the schema abandons them or leaves them inconsistent. - **The DDL itself is a risk.** On a large table, adding or dropping a column, adding a `NOT NULL` constraint, or rebuilding an index can lock or rewrite the table for a long time. Running that under incident pressure adds a second, larger outage on top of the first. - **It takes as long as it takes.** A rollback whose duration is proportional to your table size is not a rollback. So the reverse migration is a development convenience. In production, treat the schema as append-only and forward-moving, and make the *code* the reversible part. ## Expand-contract, step by step The rule: **every schema state must be usable by both the version before and the version after it.** For a column rename from `email` to `email_address`: 1. **Expand.** Add `email_address` as nullable with no constraint. Non-destructive; the running code does not know it exists. Deploy the migration on its own. 2. **Dual-write.** Deploy code that writes both columns and still reads `email`. Rolling back to step 1's code is safe - it only ignores the new column. 3. **Backfill.** Copy existing rows in bounded batches, throttled so it does not saturate the database. Idempotent and resumable, so a failure mid-run is not a crisis. Verify with a count of rows still unmigrated. 4. **Switch reads.** Deploy code that reads `email_address` and still writes both. Rolling back to step 2's code is safe, because both columns are still current. 5. **Stop writing the old column.** Now the old column stops receiving writes - this is the first point where rolling back past it means losing data written since. 6. **Contract.** Drop `email`, after you are confident you will not roll back past step 5, and after any consumers you do not control (reporting jobs, exports, another service reading the same table) have moved. Steps 1-4 are all reversible by redeploying the previous artifact. That is the whole point: the reversible thing is the code, and the schema only ever moves in a direction that both versions tolerate. ```sql -- 1. expand: additive, no rewrite of existing rows in modern engines ALTER TABLE users ADD COLUMN email_address text; -- 3. backfill in bounded batches, resumable UPDATE users SET email_address = email WHERE email_address IS NULL AND id >= 1 AND id < 10000; -- 6. contract: only after the rollback window has closed ALTER TABLE users DROP COLUMN email; ``` ## The rules worth stating in an interview - **Never ship a destructive DDL in the same deploy as the code that depends on it.** Migration and code are separate, ordered deploys. - **Every migration must be compatible with the previously deployed version of the code (N-1).** Expansion leads the code; contraction trails it. - **Adding a `NOT NULL` column with no default is the classic trap** - the old code does not know the column and its inserts start failing. Add nullable or with a default, backfill, then constrain. - **The rollback window sets the contract schedule.** If you are willing to roll back for a week, the drop happens a week later, not in the same change. ## The honest cost A one-line rename becomes four to six deploys over several days, plus a backfill to babysit and dual-write code to clean up afterwards. Teams skip it because it feels like ceremony for a trivial change. The trade is explicit: you are paying a few days of process to keep every release in the sequence rollback-safe. On a service where a bad release means a five-minute rollback instead of an hour of forward debugging, that trade is nearly always worth it - and the discipline only helps if it is the default, because you cannot retrofit it during the incident.

  • Adding a NOT NULL column with no default breaks the previous version even though it only adds. Why?
    The previously deployed code does not know the column exists, so its INSERT statements omit it - and the constraint rejects every one of those inserts. The additive change is still incompatible with N-1. The safe sequence is to add the column nullable or with a default, deploy code that populates it, backfill existing rows, and only then add the NOT NULL constraint.
  • How do you decide when it is safe to run the contract step?
    When you no longer intend to roll back past the deploy that stopped writing the old shape, and when every reader has moved - including ones outside your service, such as analytics jobs, exports or another team querying the same table. In practice teams pick an explicit rollback window, a week or a sprint, and schedule the drop after it. Verify with query logs or a usage counter rather than assuming.
  • Dual-writing during a rename means two writes per request. What can go wrong?
    They can diverge: one write succeeds and the other fails, or a code path is missed and updates only the old column. Write both inside the same transaction where the store allows it, add a reconciliation check comparing the two columns on a sample of rows, and treat any drift as a blocker on the read switch. The dual-write phase also carries a real latency and write-amplification cost, which argues for keeping it short.

Expand-contract is building the new lane before you close the old one: for a while both lanes carry traffic, which costs more, but at no point is the road unusable in either direction.

saying these in an interview costs you the question

  • Write a down migration for every up migration and rollback is solved
  • The schema can be rolled back as easily as the code
  • Rename the column and update the code in the same pull request
  • Rolling back the code implies rolling back the migration
  • Adding a column is always safe regardless of constraints

context