The Expand/Contract Pattern for Safe Schema Migrations

Photo by FOTO:Fortepan — ID 91477 : Adományozó/Donor: UVATERV. via Wikimedia Commons (CC BY-SA 3.0)
Expand/contract, also called parallel change, is a technique for changing a database schema with zero downtime. Instead of one breaking migration, you expand the schema to hold both the old and new shape, migrate data and application code through backward-compatible steps, then contract by removing the old shape once nothing uses it. Every intermediate state keeps a running application working.
During a rolling deploy, the old and new versions of your application run at the same time for several minutes. A single rename creates a window where one app version expects the old column name and the other expects the new one, so one of them breaks. Expand/contract avoids this by keeping both columns live until every deployed app instance reads the new one.
On PostgreSQL 11 and later, adding a column with a constant default is a fast metadata-only change and does not rewrite the table. However, a volatile default like a random UUID still forces a full rewrite under an ACCESS EXCLUSIVE lock, and adding NOT NULL scans every row. The safe expand step adds the column as nullable and defers constraints to a later step.
Never run one giant UPDATE across millions of rows — it holds locks for the entire transaction and bloats the table. Instead update in small batches, a few thousand rows per transaction, committing between batches with a short pause. This keeps locks brief, lets autovacuum keep up, and limits the blast radius of any failure to the current batch.
Adding NOT NULL directly scans the whole table under a heavy lock. Instead add a CHECK constraint with the NOT VALID clause, which is instant because it skips existing rows, then run VALIDATE CONSTRAINT separately. Validation uses a lighter SHARE UPDATE EXCLUSIVE lock that does not block concurrent reads and writes.

Photo by FOTO:Fortepan — ID 91477 : Adományozó/Donor: UVATERV. via Wikimedia Commons (CC BY-SA 3.0)
Key Takeaway
Expand/contract splits one risky schema change into five deploys that each stay backward compatible: expand the schema, backfill data, dual-write to keep both columns live, switch reads to the new column, then contract by dropping the old one. No single step breaks a running app, so you migrate a live database with zero downtime.
The scariest deploy I run is not a code change. It is a schema change on a table that a live service is reading and writing thousands of times a second. Rename a column in a single migration and you have a window where the deployed app expects the old name and the database only has the new one, or vice versa. During a rolling deploy both versions of the app run at the same time, so there is no instant where a breaking rename is safe.
The fix is a pattern with several names: expand/contract, or parallel change. Instead of one atomic change, you make the schema hold the old and new shape at the same time, migrate through it in small backward-compatible steps, and only remove the old shape once nothing reads it anymore. I use it for every destructive schema change on a table that matters.
The whole pattern is a promise: at no point does the database and the currently deployed app disagree about what must exist. Each step is its own commit and its own deploy. You never combine an expand with a contract in the same release. Here is the shape of it, using a classic example: splitting a single full_name column into first_name and last_name.
| Step | Action | App still works because |
|---|---|---|
| 1. Expand | Add the new nullable columns; do not touch old ones | Old code ignores columns it does not know about |
| 2. Backfill | Copy existing data into the new columns in batches | Reads still use the old column; new one is filling up |
| 3. Dual-write | Deploy code that writes to both old and new columns | Both columns stay in sync for every new row |
| 4. Switch | Deploy code that reads from the new columns | New columns are now fully populated and trusted |
| 5. Contract | Stop writing the old column, then drop it | Nothing reads or writes it anymore |
The expand step must be cheap. On PostgreSQL 11 and later, adding a column with a constant default is a metadata-only operation stored in the system catalog and applied on read, so it no longer rewrites the table. Before 11, that same statement rewrote every row under an ACCESS EXCLUSIVE lock, which on a large table meant minutes of blocked traffic. Even on modern Postgres, a volatile default such as a random UUID still forces a full rewrite, so add the column nullable and backfill separately.
-- Expand: cheap, metadata-only, no table rewrite.
-- Add columns as NULLABLE. Do NOT add NOT NULL yet --
-- an unpopulated NOT NULL column would reject every insert.
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;Never add a NOT NULL column in the expand step. The old, still-running app does not know the column exists and will not supply a value, so every insert it makes fails. Add nullable now; add the NOT NULL constraint only after the backfill, and even then add it NOT VALID first.
A single UPDATE users SET ... across millions of rows takes one long transaction, holds row locks the whole time, and bloats the table with dead tuples that vacuum then has to chase. Batch it. Update a few thousand rows per transaction, commit, sleep briefly, repeat. This keeps each lock short, lets autovacuum keep up, and means a failure only rolls back the current batch instead of hours of work.
-- Backfill loop: run outside a single transaction.
-- Each batch commits on its own.
DO $$
DECLARE
rows_updated integer;
BEGIN
LOOP
UPDATE users
SET first_name = split_part(full_name, ' ', 1),
last_name = substr(full_name, length(split_part(full_name,' ',1)) + 2)
WHERE first_name IS NULL
AND id IN (
SELECT id FROM users WHERE first_name IS NULL LIMIT 5000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT; -- procedural COMMIT, Postgres 11+
PERFORM pg_sleep(0.1);
END LOOP;
END $$;Once the backfill covers historical rows, you still have a gap: rows written between the backfill and the switch would only populate the old column. Dual-write closes it. Deploy application code that writes both the old full_name and the new first_name/last_name on every insert and update. Now both representations stay identical going forward. Only after that deploy is fully rolled out do you ship the next one that reads from the new columns.
// Step 3 deploy: write BOTH shapes. Reads still use full_name.
await db.user.update({
where: { id },
data: {
fullName: `${firstName} ${lastName}`, // old, still authoritative
firstName, // new, now kept in sync
lastName,
},
});
// Step 4 deploy (next release): reads switch to the new columns.
// Writes still populate both until the contract step lands.
const { firstName, lastName } = await db.user.findUniqueOrThrow({
where: { id },
select: { firstName: true, lastName: true },
});Put a real gap between the switch (step 4) and the contract (step 5) — at least one full deploy cycle, ideally a day or two. That window is your safety net: if reading the new column surfaces a backfill bug, the old column is still populated and correct, so rollback is a one-line revert instead of an incident.
Contract is the mirror of expand. First deploy code that stops writing the old column, confirm nothing references it, then drop it in a final migration. This is also where you promote the new columns to NOT NULL if the domain requires it. Adding NOT NULL directly scans the whole table under a heavy lock; instead add a CHECK ... NOT VALID constraint, which skips the scan, then VALIDATE CONSTRAINT separately under a lighter SHARE UPDATE EXCLUSIVE lock that does not block reads and writes.
-- Enforce NOT NULL without a blocking full-table scan:
ALTER TABLE users
ADD CONSTRAINT users_first_name_not_null
CHECK (first_name IS NOT NULL) NOT VALID; -- instant, no scan
ALTER TABLE users
VALIDATE CONSTRAINT users_first_name_not_null; -- scans, but lighter lock
-- Finally, the contract itself:
ALTER TABLE users DROP COLUMN full_name;Guard every schema-altering statement with a short lock_timeout. Even a metadata-only ALTER must briefly take ACCESS EXCLUSIVE, and if a long-running query holds the table, your ALTER queues behind it and blocks all traffic while it waits. A short timeout makes the migration fail fast and retry instead of freezing production.
Five deploys for one column rename is real overhead, and I do not use it everywhere. On a low-traffic internal table I take a short maintenance window and rename in one shot. Expand/contract earns its cost on tables that are large, hot, and fronted by a service that cannot go down. The list below is roughly how I decide.
The mental model that makes all of this click: a deploy is not one atomic thing. Your database and several versions of your app coexist for minutes at a time. Expand/contract simply refuses to ever let those coexisting versions disagree about the schema. Once you internalize that, the scary migration stops being scary — it becomes a boring checklist you run the same way every time.