Expand, migrate, contract
The expand-migrate-contract cadence, a three-deploy pattern for changing a live Postgres schema without downtime.
Picture this scenario. The invoices table stores the customer’s name as plain text in customer_name; a teammate replaces it with a foreign key, customer_id, into a customers table. They rename the column, generate the migration, and merge. The migration runs as the deploy goes out. For ninety seconds, half the app returns 500s. Then the errors stop on their own, which is the unsettling part: there’s nothing left to point at and nothing obvious to revert.
Each piece was correct on its own, the migration, the new code, and the old code; together they broke. The key is from the previous chapter: a production deploy is an atomic alias swap, so for a moment the old code and the new code are both live.
The alias swap is atomic; the cutover is not
Section titled “The alias swap is atomic; the cutover is not”When a build finishes, Vercel performs an alias swap : the production domain stops pointing at the old immutable deployment and points at the new one. The swap is atomic.
But an atomic alias swap is not an atomic cutover. The alias flips instantly; the running code does not. When the alias moves, a fleet of warm serverless instances is already running the old code. The new deployment’s fleet has to warm up, and the old fleet keeps draining in-flight requests. For a window of seconds to minutes, the old fleet (v1) and the new fleet (v2) both serve traffic against the one shared database.
That window isn’t only an accident of warmup. Vercel’s Rolling Releases let you deliberately route a fraction of traffic to the new deployment for as long as you choose, so two code versions live at once can be a deliberate, extended state of production, not just a corner case.
The migration is a separate event from the swap: it commits at one instant, and one migration plus one swap can never be a single atomic step. Walk it through with the rename. The old code runs SELECT customer_name; the new code runs SELECT customer_id.
- If the migration runs before the swap, the database has
customer_idand notcustomer_name, but the v1 fleet is still live and still asking forcustomer_name. Its queries fail. That’s the 500s. - If the migration runs after the swap, the v2 fleet is already asking for
customer_id, but the column doesn’t exist yet. Its queries fail. Same 500s, different fleet.
There is no third ordering, and no way to wish the window away: two fleets that want different shapes are alive at once.
Scrub the sequence below to watch the overlap moment appear, then disappear.
During the overlap window, the schema must be valid for every code version that is live.
A one-shot rename is an outage because it produces a schema valid for exactly one fleet at a time, while two fleets are live.
Three deploys: expand, migrate, contract
Section titled “Three deploys: expand, migrate, contract”If no single migration can satisfy both fleets, split the change across three deploys, arranged so every intermediate database state is valid for whatever code is live. The old and new shapes coexist, and that coexistence keeps the app up. Each deploy keeps both versions working at a different stage:
- Expand. Add the new shape alongside the old one. The old code is untouched and reads nothing that changed, so it keeps working.
- Migrate. Write both shapes on every mutation, and read the new one with a fallback to the old; a backfill fills the history. Now both columns hold the truth.
- Contract. Once nothing reads the old shape, drop it. Only the new code is left, and it needs only the new shape.
This costs calendar time, often one to three weeks for a column rename, because each deploy must soak before the next ships: the wait lets the old fleet drain before you remove what it depends on. Not every change needs all three steps, a nullable column nothing reads is one deploy, but this lesson assumes yours does.
Expand: add the new shape without touching old code
Section titled “Expand: add the new shape without touching old code”The first deploy has one rule: additive only, never destructive. You add a nullable column, a table, or an index, and change nothing the old code already reads.
The old code stays safe for two reasons. The new column is nullable, so every insert it performs still satisfies the schema, leaving the column null. And it never names the new column, so no query can break on it. The schema now carries both shapes, and v1 runs against it without noticing.
The PR is small: the edit to db/schema.ts, the migration SQL it generates, and ideally no app-code change. You’re not writing or reading the new column yet, just making room.
Here’s the migration the expand PR ships for the rename:
ALTER TABLE invoices ADD COLUMN customer_id uuid REFERENCES customers(id);Two facts about that line matter. The column is nullable, with no NOT NULL, because every existing row lacks a customer_id and would violate the constraint the instant it was added. And the foreign key is safe now, since it points at customers(id) without touching anything the old code reads. (Adding it without holding a heavy lock on a large table is the next lesson’s topic.)
This is still the ordinary migration loop: drizzle-kit generate --name expand_invoices_customer_id, review the SQL, then migrate.
Rolling expand back is cheap, and it shows what rollback means across the cadence. There are no down migrations: to undo expand you git revert the (empty) app changes and ship a forward migration dropping the new, still-unread column, safe because nothing reads it. Already, “rolling back” means “rolling forward to a safe state.”
Migrate: dual-write, backfill, dual-read
Section titled “Migrate: dual-write, backfill, dual-read”The second deploy makes the system correct against either schema state: whichever fleet a request lands on, it reads and writes consistently.
After expand, the new column is empty, both for historical rows and for any row written by code that doesn’t know it exists. Migrate closes both gaps: new writes fill both columns, and a backfill fills in the past. By the end, both columns hold the truth, and that redundancy is the safety the whole cadence buys you. The next three parts are the write path, the history, then the read path.
Dual-write: the write path that feeds both columns
Section titled “Dual-write: the write path that feeds both columns”Inside the server action (or the query helper it calls) that already mutates the invoice row, write both customerName and customerId in the same statement. Drizzle’s insert and update don’t care: hand them both fields and they go out together.
The dual-write is safe because it’s structural, not opt-in. It lives in the one code path the app already uses to mutate an invoice, so every create and update hits both columns, whether or not the developer touching some unrelated feature remembers a migration is in flight. Were it a per-call-site step, one missed site would silently rot a row you’d find weeks later. The dual-write is also temporary: contract removes it.
The walkthrough shows it inside an otherwise-ordinary updateInvoice action, with auth and validation elided.
'use server';
export const updateInvoice = async (input: UpdateInvoiceInput) => { const { id, customerId, customerName } = parse(input); const { orgId } = await requireOrgUser();
await tenantDb(orgId) .update(invoices) .set({ customerId, customerName }) .where(eq(invoices.id, id));
updateTag(invoiceTags.record(orgId, id)); return ok({ id });};The familiar opening: parse the input, lift orgId off the session. The same parse → authorize shape you write for every action.
'use server';
export const updateInvoice = async (input: UpdateInvoiceInput) => { const { id, customerId, customerName } = parse(input); const { orgId } = await requireOrgUser();
await tenantDb(orgId) .update(invoices) .set({ customerId, customerName }) .where(eq(invoices.id, id));
updateTag(invoiceTags.record(orgId, id)); return ok({ id });};The mutation, and the whole point of the deploy. One update, both columns set in the same statement, so the row can never end up with one filled and the other stale.
'use server';
export const updateInvoice = async (input: UpdateInvoiceInput) => { const { id, customerId, customerName } = parse(input); const { orgId } = await requireOrgUser();
await tenantDb(orgId) .update(invoices) .set({ customerId, customerName }) .where(eq(invoices.id, id));
updateTag(invoiceTags.record(orgId, id)); return ok({ id });};This tenantDb(orgId).update(invoices) is the only place an invoice row is mutated, so every write flows through it and hits both columns automatically. No caller has to opt in.
The backfill: bounded, batched, idempotent
Section titled “The backfill: bounded, batched, idempotent”Dual-write handles every row written from now on. The rows that existed before migrate shipped are the backfill’s job: a one-time pass that populates the new column for historical rows.
Three properties make a backfill safe:
- Bounded and batched. Update 1,000 to 10,000 rows at a time, never in one statement. A single
UPDATEacross millions of rows holds a lock for the whole run while the app’s own writes pile up behind it: a self-inflicted outage. Loop instead, each batch in its own transaction, so locks release quickly. - Idempotent. Guard the update with
WHERE customer_id IS NULL. Running the script twice is then a no-op on rows that already have a value, and if it crashes halfway you just run it again. - Run from the right place. For a small or medium table, a one-shot
scripts/backfill_customer_ids.tsrun from your machine against the unpooled connection (dbUnpooled) is plenty. For millions of rows you want observable, resumable, and off your laptop, reach for a background job on Trigger.dev, covered in its own chapter.
import { dbUnpooled } from '@/db';import { sql } from 'drizzle-orm';
const BATCH_SIZE = 5000;
while (true) { const batch = await dbUnpooled.execute(sql` UPDATE invoices SET customer_id = customers.id FROM customers WHERE invoices.customer_name = customers.name AND invoices.id IN ( SELECT id FROM invoices WHERE customer_id IS NULL LIMIT ${BATCH_SIZE} ) `); if (batch.rowCount === 0) break;}All three properties show up in the loop: WHERE customer_id IS NULL is the idempotency guard; the LIMIT ${BATCH_SIZE} subquery is the batching, since Postgres UPDATE has no LIMIT of its own; and if (batch.rowCount === 0) break makes it resumable, running until a pass changes nothing.
Don’t run this blind: the lesson after next rehearses it on a production-shaped copy first, to learn how long it takes and what it locks.
Dual-read: the fall-through while history catches up
Section titled “Dual-read: the fall-through while history catches up”Inside migrate the data is mixed: the backfill is partway through, so some rows have customer_id and some still only have customer_name. The read path has to return a sensible value either way.
The fix is a fall-through: read the new value, fall back to the old one when the new is null. In SQL that’s a coalesce, placed in the query helper that reads invoices so it’s defined once, not copy-pasted across every call site.
// Prefer the joined customer (reached via customer_id); fall back to the legacy text column.customerName: sql<string>`coalesce(${customers.name}, ${invoices.customerName})`,It coalesces the joined customers.name, reached through the new customer_id foreign key, with the legacy invoices.customerName text column, not the two raw columns directly (one is a uuid, the other text). Every read then gets a consistent name however far the backfill has progressed.
Like the dual-write, this fall-through is scaffolding contract removes once every row has a customer_id.
When the new column powers a new feature rather than just renaming a value, gate the read path behind a feature flag : it rolls the new behavior out in stages and is the fastest rollback if the data has a quality problem, no deploy needed. Reach for it only when the change is also a behavior change; the next lesson draws that line.
The system is now correct against either schema state, and fully reversible by git revert, because the old column still holds the truth. If migrate was a mistake, you revert the app PR and reads fall through to customer_name again. No migration to undo, no data lost.
Contract: drop the old shape once it’s unread
Section titled “Contract: drop the old shape once it’s unread”The third deploy returns the schema to one clean shape. It has one hard precondition.
Contract is safe only after the new code has run long enough that nothing reads the old shape: no live function, no cron job, no one-off script, no integration you forgot about. The lesson after next shows how to prove that; here, hold it as the gate. Don’t drop customer_name until you’re certain nobody asks for it.
The contract PR holds three things: the schema edit dropping the old column, the generated SQL, and the app-code cleanup that deletes the dual-write and dual-read fall-through from migrate.
ALTER TABLE invoices DROP COLUMN customer_name;ALTER TABLE invoices ALTER COLUMN customer_id SET NOT NULL;The SET NOT NULL promotion is safe now. At expand it would have rejected every existing row, since none had a customer_id yet. The backfill filled the history and the dual-write covered everything since, so every row now holds a customer_id, with no nulls left to reject.
Contract is the only irreversible step. git revert restores code, not dropped bytes: once customer_name is gone, its values are gone. That’s why it ships last, and only once you’re certain.
Forward-only migrations: what rollback can’t undo
Section titled “Forward-only migrations: what rollback can’t undo”Why the care, when instant rollback from the previous chapter recovers in seconds? Because re-promoting the last good deployment brings back code, not a dropped column.
Recall the rule from Drizzle: migrations are forward-only, with no down migrations. Every step in the cadence is a forward migration that leaves the system runnable, so “rolling back” means rolling forward to a known-safe state, never running a migration in reverse. Each step gets there differently: expand and migrate roll back by deploy, while contract is a data-recovery job, which is why it ships last.
git revert the empty app PR, plus a forward-fix migration that drops the unread new column.
git revert the app PR — no migration. Reads fall through to the old column, which still holds the truth.
Not a deploy at all. Re-add the column, then backfill it from a known good source — a snapshot, replica, or export.
Each step earns its place by one test: does it leave production runnable on the previous deploy? Expand and migrate do. Contract does too, but only because by the time it ships, the previous deploy has already stopped needing the old column. Last chapter’s rollback is the cure, and it works for code; the cadence is the prevention, and you need it because the cure can’t reach a forward-only migration.
When the cadence isn’t enough
Section titled “When the cadence isn’t enough”The cadence makes the shape change safe, not the change as a whole. A behavior change riding along needs its own feature-flag rollout to turn on for everyone at once. And a backfill that corrects existing values rather than copying them is a data migration with its own correctness story: you must verify the values are right, not just present.
Practice: order the cadence
Section titled “Practice: order the cadence”What matters here isn’t the three words but their order. The drill below scrambles every step from all three deploys; put them back into the sequence they’d actually run.
Order the steps of a full expand-migrate-contract cadence for renaming `customer_name` to a `customer_id` foreign key. Drag the items into the correct order, then press Check.
customer_id column (expand migration) customer_name and customer_id in the invoice action customer_name when the customer_id join yields nothing, in the query helper customer_name customer_name column (contract migration) customer_id to NOT NULL A teammate ships the customer_name → customer_id rename in a single PR — the schema migration and the code change deploy together — reasoning that “the Vercel deploy is atomic, so there’s no gap to worry about.” For roughly a minute after the deploy, a chunk of requests 500, then it heals on its own. What was actually happening during that minute?
Suppose each of the three deploys has already shipped to production and you now need to walk one of them back. Which step is the only one where git revert plus re-promoting the previous deployment is not enough to recover?
ADD COLUMN is the hardest part to reverse.customer_name, which still holds the truth. Contract is the odd one out because it DROP COLUMNs the old shape: git revert restores code, not deleted bytes. Recovering past a contract isn’t a deploy at all — it’s re-adding the column and backfilling it from a snapshot or replica. That irreversibility is exactly why contract ships last, and only once you’re certain nothing reads the old column.External resources
Section titled “External resources”If you want the same idea told by teams who run it at scale, these write-ups on online schema change and the expand-contract pattern are worth reading.
Prisma's Data Guide walks the full pattern step by step, with diagrams for each stage, the canonical written companion to this lesson.
GoCardless on the Postgres locking traps, including why adding a foreign key can lock a busy table, the detail the next lesson picks up.
How a database company runs non-blocking, online schema changes at scale, with branching and deploy requests.