Schema changes & migrations

Expand/Contract: The Pattern Behind Every Safe Migration

In a rolling deployment, old and new application code run simultaneously for a window. If a migration changes the schema in a way only the new code understands, the old code breaks — and if you deploy code first, it references columns that do not yet exist. The expand/contract pattern (also called parallel change) makes every step backward-compatible so old and new code coexist safely.

The one thing to understand first

In a rolling deployment, old and new application code run simultaneously for a window. If a migration changes the schema in a way only the new code understands, the old code breaks — and if you deploy code first, it references columns that do not yet exist. The expand/contract pattern (also called parallel change) makes every step backward-compatible so old and new code coexist safely.

Never let schema and code change in lockstep: add the new shape, run both shapes in parallel until all old code is gone, then remove the old shape — three deploys, not one. Every "safe migration" technique in this pathway is just a special case of this parallel-change discipline.

The three phases

  • Expand. Add the new schema elements additively — new columns/tables alongside the old ones. Nothing is removed; old code is unaffected.
  • Migrate. Make both shapes consistent: dual-write from the application (or via triggers), backfill historical data, and switch reads to the new shape once it is complete.
  • Contract. After all code uses the new shape and you are confident, remove the old elements.

Each phase is deployed and verified independently. A rollback at any point is safe because earlier phases left the old shape intact.

Worked example: renaming a column

A direct ALTER TABLE ... RENAME COLUMN would instantly break whichever code version does not expect the new name. Expand/contract instead:

Lab-verified
SQL
-- EXPAND: add the new column (fast, additive)
ALTER TABLE users ADD COLUMN full_name text;

-- MIGRATE: dual-write. Either in app code (write both columns)
-- or with a trigger that keeps them in sync:
CREATE FUNCTION sync_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := COALESCE(NEW.full_name, NEW.name);
  NEW.name      := COALESCE(NEW.name, NEW.full_name);
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_name BEFORE INSERT OR UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION sync_name();

-- Backfill existing rows in batches
UPDATE users SET full_name = name WHERE full_name IS NULL;  -- batched

-- Deploy new code that reads/writes full_name.

-- CONTRACT: once nothing uses name, drop the trigger and column
DROP TRIGGER trg_sync_name ON users;
ALTER TABLE users DROP COLUMN name;
Real captured output for this query is in the Pro lab notebook below.

Changing a column type the same way

To change amount integer to numeric: add amount_num numeric, dual-write/backfill, switch reads, then drop amount and (optionally) rename. The table is never rewritten under a long lock; the heavy work is a batched backfill that runs online.

Splitting and merging tables

The same discipline handles larger refactors. To extract addresses out of users: create the new table (expand), dual-write inserts/updates to both and backfill (migrate), point reads at the new table (switch), then stop writing the old columns and drop them (contract). Foreign keys are added NOT VALID first, then validated.

Layer 3 — Watch it happen on your own database

Reference
SQL
-- During the MIGRATE phase, prove the two shapes stay in sync.
-- Any drift between old and new columns is a dual-write bug:
SELECT count(*) AS out_of_sync
FROM   users
WHERE  full_name IS DISTINCT FROM name;     -- expect 0 once backfilled

-- Confirm the backfill is complete before switching reads:
SELECT count(*) AS unbackfilled FROM users WHERE full_name IS NULL;

-- Before CONTRACT: confirm nothing still writes the old column
-- (check application logs / pg_stat_user_tables n_tup_upd trends).

The single number that gates each phase transition is "are old and new in agreement?" Run the IS DISTINCT FROM check during the dual-write window: it must reach zero before you switch reads, and stay zero before you contract. This is how you turn an abstract pattern into a checklist with a green light at each step.

Layer 4 — The levers this hands you

Expand/contract buys true zero downtime, safe rollback, and decoupled deploys — provided you respect the discipline:

  • Never combine expand and contract in one release. Until the contract phase, reverting code is harmless because the old shape still exists and is still maintained.
  • Keep the dual-write window long enough to be certain every old code path is gone — verify, don't assume.
  • Backfill in batches (next article), never one statement; pair with a low lock_timeout.
  • **Add FKs/constraints NOT VALID then VALIDATE** (previous article) so the new shape's integrity comes online without a blocking scan.
  • **Dual-write via app code or a BEFORE INSERT OR UPDATE trigger** — either keeps both columns consistent during migration.

Layer 5 — What an Oracle DBA should expect vs what they get

Oracle bakes parallel-change into the engine; in Postgres it is a manual discipline:

  • No Edition-Based Redefinition. Oracle's EBR lets old and new code see different views of the schema simultaneously, automating the coexistence window. PostgreSQL has no EBR — you achieve the same outcome by hand with additive columns and dual-writes.
  • **No DBMS_REDEFINITION.* Oracle's online table redefinition handles type changes and table restructures internally; in Postgres, expand/contract is* that mechanism, assembled from primitives.
  • Triggers play the dual-write role Oracle's redefinition does with its materialized-view-log machinery — but you own the trigger and the backfill.
  • The upside: full visibility. Because every step is explicit SQL, rollback and verification are completely under your control rather than hidden inside a package.

Key takeaway

Expand/contract decouples schema from code: add the new shape additively (expand), keep both consistent with dual-writes and a batched backfill while switching reads (migrate), then drop the old shape (contract). Gate each transition on a sync check (full_name IS DISTINCT FROM name reaching zero), never combine expand and contract in one release, and keep the dual-write window long enough to be sure all old code is gone. It is the parallel-change pattern behind every zero-downtime migration.

Interview lens

How this shows up in interviews

What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.

What the interviewer is testing

Whether you understand backward-compatible schema evolution when old and new application code run simultaneously during a rolling deploy.

Questions you should be able to answer

  • Old and new app versions run at once during a deploy — how do you change a column safely?
  • Walk through renaming a column with zero downtime.
  • Why must expand and contract be separate deploys?

Database engineer

You never rename or drop in place under rolling deploys; you add-new, dual-write/backfill, switch reads, then drop the old later.

Software engineer

You make each deploy backward-compatible so a rollback never hits a missing column.

Feature builder

You plan schema changes as expand -> migrate code -> contract across multiple releases.

Shallow answer

'Run the ALTER during a maintenance window.' Assumes downtime and ignores that both code versions coexist.

Answer that shows depth

In a rolling deploy old and new code run at the same time, so any change only one version understands breaks the other. Expand/contract (parallel change) makes every step backward-compatible: EXPAND (add the new column/table, keep the old), migrate (dual-write from new code, backfill existing rows in batches), switch reads to the new shape once backfilled, then CONTRACT (drop the old column) only after no code references it. A rename becomes add-new + backfill + switch + drop-old across separate deploys, so any single rollback still finds a consistent schema.

How this was verified

This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.

Official documentation: §5.6 Modifying Tables