← Architecture
Deploy and evolve Expand, migrate, contract

Expand and contract

Add first, move the data, remove last.

You have renamed a field in code and let the compiler find every use. A column is different: the code that uses it is not only yours, and not only today’s. During a deploy, yesterday’s release is still serving. Let’s split a name column in a member directory while that is true.

TypeScriptGoOne member directory, three app versions, two recorded builds.

01 / The prompt

“Split full_name into given_name and family_name.”

A coworking space prints badges with the family name on its own line, and the directory lists members by family name. The table has one full_name column. Ask an agent for the change and you get a migration and a new version of the app, and on a fresh database it works.

The app deploys one instance at a time, so for a few minutes the old version and the new one serve together, against the same table. The question the prompt never asked is what the old version does when the column it writes is gone, and what the split does to names that are not two words: Mary Ann Lee, Nguyen Van An, Wati, Sofía de la Cruz.

The pattern has a name: “Parallel change, also known as expand and contract, is a pattern to implement backward-incompatible changes to an interface in a safe manner, by breaking the change into three distinct phases: expand, migrate, and contract” (Danilo Sato, Parallel Change, 2014). And names are harder than two fields: the W3C notes that in parts of Southern India, Malaysia, and Indonesia “a large number of people have names that consist of a given name only” (W3C, Personal names around the world). Both checked 23 September 2026.

02 / Name the shape

Add, move, then remove.

Expand and contract splits one incompatible change into steps that are each compatible with whatever is still running. Expand only adds: two nullable columns that v1 never names. The migrate part moves writers and data across: an app that writes both shapes, a backfill for the rows already there, and reads switched to the new columns. Contract removes the old column, last, when nothing still serving uses it.

Every step must work beside the release before it. The old column goes only when no running code uses it and every row has been moved or checked by a person.

Who owns each part of the change
WhatOwnerWhy
Which columns existThe migrations, in orderEach one runs once, before its release rolls out.
Which versions are servingThe deployA rollout has the old and new release up together; draining is a fact to check, not assume.
Writing the new columns for new rowsv2, then v3v2 writes both shapes, so v1 can still read what it wrote.
Filling the new columns for old rowsThe backfillSafe to run again; it only touches rows still empty.
Names that do not split on a ruleA person“Mary Ann Lee” has no right answer a rule can find.
Dropping full_nameThe contract gateIt checks what is serving, what is empty, and what waits for review.

Words to put in a prompt or a review

Expand
A change that only adds, so everything already running still works.
Dual write
A version that writes the old shape and the new one, for readers of either.
Backfill
A job that moves existing rows into the new shape, one safe step at a time.
Contract
Removing the old shape, last, once nothing depends on it.
Rolling deploy
Replacing instances one by one, so two releases serve at once.
Contract gate
The check that must pass before the old shape is removed.
Where else it appliesAPIs, events, files

The same three steps change an API field, an event’s payload, or a file format: add the new field beside the old, move producers and consumers, and remove the old one when no client sends or reads it. Versioning and compatibility covers the contract side, and Strangler fig migration is the same idea for a whole application, one route at a time.

03 / Follow one rollout

Watch the old release meet each step.

First the one-migration version, with v1 still serving. Then the same change as expand and contract, with sign-ups arriving through whichever version is up. Open Try it to run the steps in any order.

Expand and contract

Who still reads full_name?

serving v1v3

  1. deployroll out v3serving v1, v3
members
idfull_name
1Ana Souza
2Kofi Mensah
3Mary Ann Lee
4Nguyen Van An
5Wati
6Sofía de la Cruz
01/ 06
One migration, v1 still serving

One migration, v1 still serving

deploy: roll out v3 → serving v1, v3

Reduced motion: choose a scene to see its completed state.

Read this scene

deploy: roll out v3 → serving v1, v3

deploy: roll out v3, serving v1, v3.

Watch restarts the story when you come back. Step through keeps your step. Try it keeps one directory until you reset it.

04 / Read the shape

Three versions, four steps, one plan.

Basic form is the split and the app versions. In the wild is expand, the backfill, and the contract gate, beside the one migration. At the call site is the release plan.

Notice that the backfill never guesses: a name that is not two words goes on a list for a person, and the contract waits for that list to be empty.

The name split, which refuses anything that is not exactly two words, and the three app versions: v1 knows full_name, v2 writes both shapes, v3 knows only the new columns.

TypeScriptReading
directory.ts
// A name splits only when it is exactly two words. Anything else waits for a
// person: "Mary Ann Lee", "Nguyen Van An", "Wati", and "Sofía de la Cruz" do
// not split on a rule.
export function splitName(fullName: string): { given: string; family: string } | null {
	const words = fullName.trim().split(/\s+/);
	return words.length === 2 ? { given: words[0], family: words[1] } : null;
}

// Three versions of the app. v1 knows only full_name; v2 writes both shapes
// and reads the new one when it is there; v3 knows only the new columns.
export const apps = {
	v1: {
		signUp: (t: Table, name: string) => insert(t, { full_name: name }),
		show: (t: Table, id: number) => read(t, id, ['full_name'])[0]
	},
	v2: {
		signUp: (t: Table, given: string, family: string) =>
			insert(t, {
				full_name: family ? `${given} ${family}` : given,
				given_name: given,
				family_name: family
			}),
		show(t: Table, id: number) {
			const [given, family, full] = read(t, id, ['given_name', 'family_name', 'full_name']);
			return given !== null ? directory(given, family) : full;
		}
	},
	v3: {
		signUp: (t: Table, given: string, family: string) =>
			insert(t, { given_name: given, family_name: family }),
		show: (t: Table, id: number) =>
			directory(...(read(t, id, ['given_name', 'family_name']) as [Value, Value]))
	}
};
GoAlongside
main.go
// SplitName splits a name only when it is exactly two words. Anything else
// waits for a person: "Mary Ann Lee", "Nguyen Van An", "Wati", and
// "Sofía de la Cruz" do not split on a rule.
func SplitName(fullName string) (given, family string, ok bool) {
	words := strings.Fields(fullName)
	if len(words) != 2 {
		return "", "", false
	}
	return words[0], words[1], true
}

// Three versions of the app. V1 knows only full_name; V2 writes both shapes
// and reads the new one when it is there; V3 knows only the new columns.
type App struct {
	SignUp func(t *Table, given, family string) (int, error)
	Show   func(t *Table, id int) (string, error)
}

// V1's form has one name field; it arrives as given, with family empty.
var V1 = App{
	SignUp: func(t *Table, name, _ string) (int, error) {
		return t.Insert(map[string]Value{"full_name": str(name)})
	},
	Show: func(t *Table, id int) (string, error) {
		v, err := t.Read(id, "full_name")
		if err != nil {
			return "", err
		}
		return *v[0], nil
	},
}

var V2 = App{
	SignUp: func(t *Table, given, family string) (int, error) {
		full := strings.TrimSpace(given + " " + family)
		return t.Insert(map[string]Value{"full_name": str(full), "given_name": str(given), "family_name": str(family)})
	},
	Show: func(t *Table, id int) (string, error) {
		v, err := t.Read(id, "given_name", "family_name", "full_name")
		if err != nil {
			return "", err
		}
		if v[0] != nil {
			return Directory(v[0], v[1]), nil
		}
		return *v[2], nil
	},
}

var V3 = App{
	SignUp: func(t *Table, given, family string) (int, error) {
		return t.Insert(map[string]Value{"given_name": str(given), "family_name": str(family)})
	},
	Show: func(t *Table, id int) (string, error) {
		v, err := t.Read(id, "given_name", "family_name")
		if err != nil {
			return "", err
		}
		return Directory(v[0], v[1]), nil
	},
}
The behavior these examples promiseChecked by 18 shared scenarios, and on SQLite
  • Writing or reading a column that is not there fails with SQLite’s own message: “table members has no column named full_name”, “no such column: full_name”.
  • Expand adds two nullable columns; every v1 read and write still works.
  • The backfill fills only names of exactly two words, lists the others for a person, and changes nothing the second time.
  • Contract is refused, and changes nothing, while v1 or v2 serves, while a row outside the review list has no given name, or while names wait for review.
  • The one migration splits at the first space and drops full_name at once.

Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples. A second test plays every scenario against a real SQLite table with the SQL below and gets the same answers.

The same steps in SQLSQLite; PostgreSQL differs in one place
migrations.sql
-- The member directory's schema and each step, as SQL for SQLite.
-- full_name is nullable, so v3 can insert without it before the contract.
-- (In PostgreSQL the expand step would also run ALTER COLUMN full_name DROP NOT NULL.)

-- initial
CREATE TABLE members (id INTEGER PRIMARY KEY, full_name TEXT);

-- expand: only add
ALTER TABLE members ADD COLUMN given_name TEXT;
ALTER TABLE members ADD COLUMN family_name TEXT;

-- backfill: read the empty rows, split in the app, write back one row at a time
SELECT id, full_name FROM members WHERE given_name IS NULL ORDER BY id;
UPDATE members SET given_name = ?, family_name = ? WHERE id = ?;

-- the contract gate's query: rows still without a given name
SELECT id FROM members WHERE given_name IS NULL;

-- contract
ALTER TABLE members DROP COLUMN full_name;

-- the one-migration version, as in section 05
-- ALTER TABLE members ADD COLUMN given_name TEXT;
-- ALTER TABLE members ADD COLUMN family_name TEXT;
-- UPDATE members SET given_name = substr(full_name, 1, instr(full_name, ' ') - 1),
--                    family_name = substr(full_name, instr(full_name, ' ') + 1);
-- ALTER TABLE members DROP COLUMN full_name;
Reading the TypeScriptRows are plain objects

A row is an object keyed by column, and null is SQL’s NULL. The apps are objects of two functions each, so the story can call any version against the same table. contract collects every reason before it throws, so one refusal says everything that is still wrong.

Reading the GoA nil pointer is NULL

A column’s value is a *string, so nil is NULL and "" is an empty family name, which Wati’s reviewed row needs. Each step returns an error instead of throwing, and Play records it and goes on.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types directory.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/expand-and-contract

go 1.23
one migration, v1 still serving: v1 sign-up table members has no column named full_name · "Ann Lee, Mary" · "Wati, "
expand, v1 and v2 serving: v1 sign-up id 7 · backfill filled 3 · 4 for review
contract too early: refused: v1 and v2 still write full_name; 1 row without given_name; 4 names waiting for review
the release plan: dropped full_name · columns id, given_name, family_name
directory: Lee, Mary Ann · Nguyen, Van An · Wati · de la Cruz, Sofía

05 / Review the agent’s diff

“One migration, 40 ms, all tests pass.”

It is short, it is fast, and the tests are green. Read it with v1 still serving.

The agent’s pull request

“Splits full_name into given_name and family_name in one migration. It runs in 40 ms on a copy of production, and all 31 tests pass.”

(added)-- releases/02-split-name/migrate.sql
			(added)ALTER TABLE members ADD COLUMN given_name TEXT;
			(added)ALTER TABLE members ADD COLUMN family_name TEXT;
			(added)UPDATE members SET given_name = substr(full_name, 1, instr(full_name, ' ') - 1),
			(added)                   family_name = substr(full_name, instr(full_name, ' ') + 1);
			(added)ALTER TABLE members DROP COLUMN full_name;
			
You are reviewing this change. What do you do?

06 / How it fails

The old release is still up. Decide what it may lose.

Each row is a shared scenario unless it is marked as authored.

Failure modes of splitting one column
What goes wrongWhat members seeExpand and contractOne migration
Removed too early: v1 writes a dropped columnSign-up fails during the deployContract refused while v1 or v2 serves“table members has no column named full_name”
Wrong data: a name that is not two wordsA badge reading “Ann Lee” for Mary Ann LeeFlagged for a person; contract waitsSplit at the first space, silently
Lost: the original is goneNobody can say what it wasfull_name stays until every row is checkedDropped in the same step
Half-done: a late v1 write after the backfillJonas has no given nameGate counts him; a second backfill fills himNot applicable: v1 is broken
Read too early: v3 before the backfill“(no name)”Order the plan: backfill, then v3Not applicable
Gate skipped: forced contract with v2 servingv2’s sign-ups and reads failWhat the gate is forThe same failure, by design
Slow: a backfill that locks the table (authored)Writes wait while it runsUpdate in small batches; this lesson updates one row at a timeOne long UPDATE

A backfill runs again and again, so it has to be idempotent; a rolling deploy is why Thinking in failure modes asks what happens “during” as well as “after”.

07 / Is it worth it?

Five steps instead of one. Here is what they buy.

One migration is shorter and finishes today. Expand and contract takes a week of small releases. Hold both against the changes.

The same four changes, made to each
ChangeOne migrationExpand and contract
A second reader: the badge printer reads the table nightlyBreaks the night of the deployMoves at its own pace before the contract
Replace the database: move to PostgreSQLRewrite one migrationSame steps; expand also drops NOT NULL
Change a rule: accept three-word namesAlready applied; cannot redoChange the split, run the backfill again
A second team owns the directory front endThey deploy on your scheduleThey switch reads when they are ready; the gate waits

Before starting, decide what you will measure and the result you would accept:

  • Errors that name the old column in logs during each rollout: none.
  • Rows without a given name, and names waiting for review, before the contract: zero of each.
  • Queries that still name full_name, from the database’s query log or pg_stat_statements, for a full week of traffic before the drop: none.

Take the baseline on today’s release first: how many queries name full_name, and from which services. This page did not run a production database, so it gives no numbers.

08 / Ask for it

Two prompts, two plans, four hard names.

We sent two agents the same change at the same time, both running Claude Sonnet, each in a copy of the directory’s release 01 with a README explaining that the previous release serves during every rollout. One prompt described the change. The other added an Architecture block: expand only adds, a release that writes both shapes, a backfill that fills only two-word names and lists the rest for support staff, and a drop in its own last release behind a check. A script then deployed each build release by release, with the old release serving and sign-ups arriving through both.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt
Releases after 0102-add-name-columns, 03-switch-to-split-name-api, 04-stop-writing-full-name, 05-drop-full-name02-expand-name-columns, 03-dual-write, 04-name-parts-contract, 05-stop-full-name, 06-drop-full-name
Requests that failed during a rolloutnone of 20none of 20
Old-form sign-ups on the new app02-add-name-columns: 201; 03-switch-to-split-name-api: 400; 04-stop-writing-full-name: 400; 05-drop-full-name: 40002-expand-name-columns: 201; 03-dual-write: 201; 04-name-parts-contract: 400; 05-stop-full-name: 400
Two-word names at the end8 of 8 right9 of 9 right
Mary Ann Lee, Nguyen Van An, Wati, Sofía de la CruzMary Ann / Lee; Nguyen Van / An; Wati / ; Sofía de la / CruzMary Ann / Lee; Van An / Nguyen; null / null; Sofía / de la Cruz
Did a check stop a release?no06-drop-full-name: “check failed: 7 member(s) still missing given_name/family_name: | id=3 given_name=null family_name=null | id=4 given_name=null family_name=null”; after the operator's PUTs (200, 200, 200, 200, 400, 200, 200, 200, 200, 200, 200, 200, 200), it still refused
Columns at the endid, given_name, family_nameid, full_name, given_name, family_name
The build’s own tests13 of 13 pass20 of 20 pass

Both agents split the change into releases, and neither broke the old release: no request failed with a server error in either rollout. The README’s one sentence about the previous release serving did the architecture prompt’s job, as the platform’s README did in Long-running servers vs. functions. The one-migration failure in section 03 is the lesson’s own.

The difference is the names. The plain build’s backfill splits every name by one rule, “the last word is the family name”. That is right for Mary Ann Lee and wrong for Nguyen Van An, whose family name comes first, and for Sofía de la Cruz. Its check only asks whether the columns are filled, so it passed, the drop ran, and the names as members typed them are gone.

releases/02-add-name-columns/backfill.mjs · plain prompt
export function splitName(fullName) {
	const tokens = fullName.trim().split(/\s+/).filter(Boolean);
	if (tokens.length <= 1) return { given_name: tokens[0] ?? '', family_name: '' };
	return { given_name: tokens.slice(0, -1).join(' '), family_name: tokens[tokens.length - 1] };
}
releases/05-stop-full-name/app.mjs · architecture prompt
function validateNameParts(body, res) {
	if (typeof body?.given_name !== 'string' || !body.given_name.trim()) {
		send(res, 400, { error: 'given_name is required' });
		return null;
	}
	if (typeof body?.family_name !== 'string' || !body.family_name.trim()) {
		send(res, 400, { error: 'family_name is required' });
		return null;
	}
	return { givenName: body.given_name.trim(), familyName: body.family_name.trim() };
}

The architecture build never guessed, and it never finished. Its backfill ran once, before the rollout, so three members that old instances signed up afterward had no names; its drop release’s check caught them, and the checker, acting as support staff, set every name through PUT. One still failed: PUT refuses an empty family name, and the check requires one, so Wati can never be entered and full_name can never be dropped. Nothing was lost, and nothing shipped. Both builds also stopped accepting the old sign-up form in the release that switched the API, so a page that moved off a replaced instance got a 400.

So the line worth adding to either prompt is about the data, not the releases: some names are one word, and some put the family name first; the backfill fills only what it is sure of and lists the rest for a person, runs again after the last old writer is gone, and the person’s tool accepts every real name, including one with no family name.

How the runs were made and checkedOne sample of each prompt
  • Both agents received the prompts word for word, in fresh contexts, in the same message. The prompt files were kept outside the run folders’ parent.
  • The files each agent wrote are kept byte for byte, with checksums. The checker restores each build over a fresh database and deploys it release by release on ports 5240 and 5241.
  • Both stopped their apps by process id. The plain agent moved a test helper before fixing the path it computes, and one test run wrote 13 empty database files in the folder above its own; nothing read them.
  • The checker’s first run set only the seeded names when a check refused, which left the architecture build blocked by three members the check had also listed. The second run, which this table shows, sets every name it knows. Both runs are kept.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Keep the drop behind a gate, by rule and by test.

Expand and contract erodes the first time someone is in a hurry and puts the drop in the same release as the change. Three checks.

  1. The database’s own door

    Adding a nullable column is cheap in both engines. In PostgreSQL, with no default “NULL is used as the DEFAULT. In neither case is a rewrite of the table required” (PostgreSQL, ALTER TABLE); in SQLite, the time an ADD COLUMN takes “is independent of the amount of data in the table” (SQLite, ALTER TABLE). Dropping is the step to guard: SQLite’s DROP COLUMN “rewrites its content to purge the data associated with that column”, and nothing brings it back. Checked 23 September 2026.

  2. A rule a check enforces

    No migration may both add and drop in the same release, and a DROP COLUMN must name a column that no app source still mentions. Enforcement layer runs rules like this against real code, and Fitness functions turns them into a check on every change. This rule was not run here.

  3. A check on what actually happens

    Deploy the releases in order against a copy of the data, with the previous release still serving during each rollout, and send it the old form. Any 5xx fails the plan. The checker in section 08 does exactly this.

    check-runs.mjs
    await record('old', 'POST', '/members', { name: oldForm[n] }, oldForm[n]);
    await record('old', 'GET', '/members/1');
    await record('new', 'POST', '/members', { name: movedForm[n] }, movedForm[n]);
    const [given, family] = newForm[n];
    await record('new', 'POST', '/members', { given_name: given, family_name: family }, `${given} ${family}`);
    await record('new', 'GET', '/members/1');
Your front end is an old release tooAn open tab runs last week’s bundle. A draft saved by it is data written by the old version.

Where it already is in your components

During a rolling deploy, the API your page calls may be the old release or the new one, so a component that reads member.given_name has to handle a reply with only name for a few minutes. Every ?? fallback you wrote for a renamed field is the migrate step of an expand and contract.

When you have to own it

A sign-up form saves a draft in localStorage. A tab opened last week still runs the old bundle and writes { name }; the new bundle writes both shapes until no old bundle can be open, reads either, and shows an old draft’s name instead of splitting it. The helper below is shared by both versions.

signup-draft.ts
// The sign-up form saves a draft in localStorage. A tab still running last
// week's bundle reads and writes { name }; this bundle writes both shapes
// until no old bundle can be open, and never guesses a split from name.
export type Draft = { given_name: string; family_name: string; oldName?: string };
const KEY = 'signup-draft';

export function readDraft(): Draft {
	try {
		const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}');
		if (typeof saved.given_name === 'string')
			return { given_name: saved.given_name, family_name: saved.family_name ?? '' };
		if (typeof saved.name === 'string' && saved.name)
			return { given_name: '', family_name: '', oldName: saved.name };
	} catch {
		// No storage, or a draft we cannot read: start empty.
	}
	return { given_name: '', family_name: '' };
}

export function saveDraft(draft: Draft) {
	const name = [draft.given_name, draft.family_name].filter(Boolean).join(' ');
	try {
		localStorage.setItem(
			KEY,
			JSON.stringify({ given_name: draft.given_name, family_name: draft.family_name, name })
		);
	} catch {
		// Storage full or blocked: the draft is only a convenience.
	}
}

A badge that reads whichever shape the API answered with during the rollout.

ReactAlready in your code
MemberBadge.tsx
// During the rollout, an old instance answers GET /members/:id with name and a
// new one with given_name and family_name. The badge reads whichever arrived.
type Member = {
	id: number;
	name?: string;
	given_name?: string | null;
	family_name?: string | null;
};

export function MemberBadge({ member }: { member: Member }) {
	if (member.given_name == null) {
		return (
			<div className="badge">
				<strong>{member.name}</strong>
			</div>
		);
	}
	return (
		<div className="badge">
			<strong>{member.family_name || member.given_name}</strong>
			{member.family_name && <span>{member.given_name}</span>}
		</div>
	);
}

10 / Make the call

Expand and contract whenever two versions can meet the same data.

Use one migration when nothing else can be running: a single instance you stop before the migration, a table no deployed code reads yet, or a maintenance window you have announced. Then the five steps buy nothing.

Use expand and contract when deploys roll, when jobs, reports, or other services read the table, or when the change might need undoing. Reopen the decision when the table is so large that the backfill itself needs a plan; that is a batching question, not a reason to skip the steps.

Take it with you

Explain it without saying “expand” or “contract”: “We add the new columns first, keep writing the old one while anything old is running, copy the old rows over and check the odd ones by hand, and delete the old column last, only when nothing uses it.” Then find the last migration in your code that dropped or renamed something, and ask what was still running when it ran.

Paste into your next prompt, and fill in the blanks

Change <old column> to <new columns> with expand and contract, because
<the previous release / a job / another service> keeps using the table while
this rolls out. Release 1 only adds <nullable columns>. Release 2 writes both
shapes. A backfill fills rows it can convert by <rule> and lists the rest for
a person; it never guesses and is safe to run again. Reads switch next. The
old column is dropped in its own, last release, behind a check that fails
while anything still serving uses it or any row is unconverted.
Connections to follow nextRelated lessons

Take the plan into your own schema. Find a column you would like to rename, and write the five releases before you write the first one.

Back to architecture →