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.
| What | Owner | Why |
|---|---|---|
| Which columns exist | The migrations, in order | Each one runs once, before its release rolls out. |
| Which versions are serving | The deploy | A rollout has the old and new release up together; draining is a fact to check, not assume. |
| Writing the new columns for new rows | v2, then v3 | v2 writes both shapes, so v1 can still read what it wrote. |
| Filling the new columns for old rows | The backfill | Safe to run again; it only touches rows still empty. |
| Names that do not split on a rule | A person | “Mary Ann Lee” has no right answer a rule can find. |
| Dropping full_name | The contract gate | It 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.
Who still reads full_name?
serving v1v3
- deployroll out v3serving v1, v3
| id | full_name |
|---|---|
| 1 | Ana Souza |
| 2 | Kofi Mensah |
| 3 | Mary Ann Lee |
| 4 | Nguyen Van An |
| 5 | Wati |
| 6 | Sofía de la Cruz |
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.
// 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]))
}
}; // 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
-- 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:
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.
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.
| What goes wrong | What members see | Expand and contract | One migration |
|---|---|---|---|
| Removed too early: v1 writes a dropped column | Sign-up fails during the deploy | Contract refused while v1 or v2 serves | “table members has no column named full_name” |
| Wrong data: a name that is not two words | A badge reading “Ann Lee” for Mary Ann Lee | Flagged for a person; contract waits | Split at the first space, silently |
| Lost: the original is gone | Nobody can say what it was | full_name stays until every row is checked | Dropped in the same step |
| Half-done: a late v1 write after the backfill | Jonas has no given name | Gate counts him; a second backfill fills him | Not applicable: v1 is broken |
| Read too early: v3 before the backfill | “(no name)” | Order the plan: backfill, then v3 | Not applicable |
| Gate skipped: forced contract with v2 serving | v2’s sign-ups and reads fail | What the gate is for | The same failure, by design |
| Slow: a backfill that locks the table (authored) | Writes wait while it runs | Update in small batches; this lesson updates one row at a time | One 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.
| Change | One migration | Expand and contract |
|---|---|---|
| A second reader: the badge printer reads the table nightly | Breaks the night of the deploy | Moves at its own pace before the contract |
| Replace the database: move to PostgreSQL | Rewrite one migration | Same steps; expand also drops NOT NULL |
| Change a rule: accept three-word names | Already applied; cannot redo | Change the split, run the backfill again |
| A second team owns the directory front end | They deploy on your schedule | They 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.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| Releases after 01 | 02-add-name-columns, 03-switch-to-split-name-api, 04-stop-writing-full-name, 05-drop-full-name | 02-expand-name-columns, 03-dual-write, 04-name-parts-contract, 05-stop-full-name, 06-drop-full-name |
| Requests that failed during a rollout | none of 20 | none of 20 |
| Old-form sign-ups on the new app | 02-add-name-columns: 201; 03-switch-to-split-name-api: 400; 04-stop-writing-full-name: 400; 05-drop-full-name: 400 | 02-expand-name-columns: 201; 03-dual-write: 201; 04-name-parts-contract: 400; 05-stop-full-name: 400 |
| Two-word names at the end | 8 of 8 right | 9 of 9 right |
| Mary Ann Lee, Nguyen Van An, Wati, Sofía de la Cruz | Mary Ann / Lee; Nguyen Van / An; Wati / ; Sofía de la / Cruz | Mary Ann / Lee; Van An / Nguyen; null / null; Sofía / de la Cruz |
| Did a check stop a release? | no | 06-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 end | id, given_name, family_name | id, full_name, given_name, family_name |
| The build’s own tests | 13 of 13 pass | 20 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.
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] };
} 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.
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 COLUMNtakes “is independent of the amount of data in the table” (SQLite, ALTER TABLE). Dropping is the step to guard: SQLite’sDROP COLUMN“rewrites its content to purge the data associated with that column”, and nothing brings it back. Checked 23 September 2026.A rule a check enforces
No migration may both add and drop in the same release, and a
DROP COLUMNmust 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.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.
// 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.
// 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
- Branch by abstraction is the same move inside the code: a seam instead of a column.
- Strangler fig migration is it for a whole application.
- Module-owned data in one database is who is allowed to change the table at all.
- Versioning and compatibility is the same question for an API.
- Idempotence is what makes a backfill safe to run twice.