Expand and Contract: The Pattern Behind Zero-Downtime API Changes
Renaming one field in a live API sounds trivial until you realize every deployed client is reading the old name. Here is the pattern that makes the change boring instead of dangerous.
A few years ago I was on a team that needed to rename a field in our API. It was called user_name. We wanted username. One word, no underscore. The kind of change that sounds embarrassingly simple until you sit down to plan it.
The options were all unpleasant. Ship a v2 of the endpoint, run two versions in parallel forever, and hope every consumer eventually migrated. Do a big-bang cutover on a Sunday night, update every client at once, and accept a window where some callers break. Or deprecate the old field and write a nice note about it, which is not a plan so much as a prayer.
The problem underneath all three is timing. A rename is a moment where the server expects one thing and a deployed client still sends or reads another. In any system with more than one client, or more than one running copy of the server, that moment is not instantaneous. It is a gap. And during the gap, something is broken.
We did not have a name for what we wanted, but we knew the shape of it: make the change safe, make the deploy boring, and let the old field disappear completely once nothing used it. That is expand and contract.
The one-sentence version
Instead of versioning the whole API, you version the change itself, moving it through phases that are each backward compatible, until the old thing no longer exists.
The standard answer to a breaking change is versioning: ship /v1 and /v2 side by side, let consumers migrate on their own schedule, maintain both. It works, but the cost is real. You now run two implementations of the same logic, two test suites, two docs pages, two sets of consumer relationships. The debt compounds. Teams routinely ship v3 before v2 clients have finished leaving v1.
Expand and contract trades that standing duplication for a bounded migration with a defined end state.
The mental model
The pattern has exactly three phases. The labels matter less than the constraint they enforce: at no point does a deployed consumer reach for something that is not there.
Picture three clients on the left, each reading a field served by the API on the right. Watch the arrows move through the phases.
Expand
Add the new shape while keeping the old one alive. Renaming a field means the response now carries both names with identical values. Nothing is removed, so every client keeps reading the old field exactly as before.
Migrate
Move consumers to the new shape one at a time. Both shapes are on the wire for the whole phase, so an updated client reads the new field while a laggard still reads the old one, and both work. No coordination, just the software deployments you were doing anyway.
Contract
Once every consumer is on the new shape, remove the old one. The contract step waits for the last laggard, so by the time the old field disappears nothing reaches for it. The API ends up cleaner than when you started.
Every deployment in that sequence is either additive or the removal of something already replaced. There is never a synchronized cutover.
A concrete example: renaming a response field
The user_name to username change, played out.
Phase 1, expand. Return both fields in every response.
function serializeUser(user: User) {
return {
id: user.id,
email: user.email,
username: user.username, // new field
user_name: user.username, // old field, same value
};
}This deploy is a pure addition. Clients reading user_name still get a value. Clients that already know about username (internal tooling, or a mobile app you shipped ahead of time) get one too. You can push it to production on a Tuesday afternoon without a maintenance window.
Phase 2, migrate. Update each consumer to read username. Any order, over days or weeks. The API serves both the entire time.
Phase 3, contract. Once every consumer reads username, drop user_name from the serializer.
function serializeUser(user: User) {
return {
id: user.id,
email: user.email,
username: user.username,
};
}Another boring deploy. The field name changed, no consumer broke, no version number moved, no docs grew a parallel section.
Where it bites hardest: database columns
The pattern is most visibly useful in schema migrations, which is where it is most often named explicitly (Danilo Sato calls it Parallel Change). Renaming a column without downtime is one of those problems that looks like it should be easy and is not.
Say a users table has a full_name column and you want display_name. Run ALTER TABLE users RENAME COLUMN full_name TO display_name and every line of code referencing full_name breaks the instant it runs. In any high-deployment environment there is always some copy of the app in flight that still references the old name.
Expand and contract turns the rename into a sequence of independently safe steps:
-- Step 1 (expand): add the new column
ALTER TABLE users ADD COLUMN display_name text;
-- Step 2 (backfill): copy existing data into it
UPDATE users SET display_name = full_name;
-- Step 3: app writes to both columns (deploy)
-- INSERT INTO users (full_name, display_name, ...) VALUES (...)
-- Step 4 (migrate reads): app reads the new column (deploy)
-- SELECT display_name FROM users WHERE ...
-- Step 5 (contract): drop the old column once nothing references it
ALTER TABLE users DROP COLUMN full_name;You can pause between any two steps if something looks wrong. You can run the backfill in batches on a large table without blocking reads. The final DROP COLUMN is not scary, because you verified nothing uses the column before you removed the last reference to it. A single RENAME COLUMN is naive in exactly one way: it assumes deployment is instantaneous, which it never is.
The request side is harder
Responses are the easy direction because the server controls what it sends. Requests are harder because the client controls what it sends.
Making an optional field required cannot be done by tightening validation alone, because existing clients that omit the field start failing. Removing a request field cannot be done by simply rejecting it, because existing clients that send it get a surprise error. The phases just run in a different order.
Adding a required field. Expand by accepting the field as optional with a documented default. Deploy. Update every client to send it explicitly. Deploy. Then tighten validation to require it.
Removing a field. Expand by making the field optional and silently ignoring it. Deploy. Remove it from every client. Deploy. Then stop accepting it, or simply never read it again.
Same principle in both directions: never remove the ability to work before clients have the ability to stop relying on it.
Splitting and merging fields
Two changes that do not fit the simple rename frame, and that traditional versioning handles badly:
Splitting. An address field holds a free-form string and you want street, city, state, postal_code. Expand by adding the four fields while keeping address, populating them by parsing the existing value during backfill. Migrate consumers to the structured fields. Contract by dropping address.
Merging. The reverse. Separate first_name and last_name, and you want one full_name. Add full_name populated by concatenation, migrate, then remove the two originals.
Versioning would force you to keep both data shapes indefinitely. Expand and contract makes each one a bounded migration with an end.
When to use it
Reach for expand and contract when you have multiple consumers you cannot update at the same instant, which is most of the time. It earns its keep when:
- Consumers ship on different schedules (mobile clients, external integrations, other teams' services).
- The field carries data that would be lost or corrupted on a failed cutover.
- You want a rollback story that does not require synchronized rollback across systems.
- The change is significant enough that you want the migration staged and auditable.
When not to use it
If a service has a single consumer you deploy together in one change, the coordination benefit is gone and the extra steps are pure overhead. Just change both sides at once.
If the change is already additive, a brand new optional field that no existing consumer reads, it is non-breaking on its own and needs no expand-and-contract ceremony. Adding the field is the whole job.
Gotchas
The pattern is not free, and the costs land mostly in the middle phase.
The expand state has a half-life of "forever" if nobody owns the migration. The migrate phase needs someone tracking which consumers have moved and which have not, and driving the stragglers through before contract. If that ownership is informal, the old field lingers, the contract step never happens, and your serializers accumulate dead fields. The pattern produces cleaner APIs only where someone owns the cleanup.
Public APIs may never contract. For a large external API, the migrate phase waits on developers you do not control, so it can run for years. During that time the expanded shape is effectively permanent. Stripe leans into exactly this: it pins each integration to the API version it was built against and makes nearly every change additive, so old integrations keep working indefinitely (see the Stripe API upgrades writeup). That is expand at company scale, with the contract step deferred more or less forever. Internal systems contract faster precisely because you own every consumer.
Both shapes on the wire invites confusion. A consumer reading the expanded response sees two fields for the same data and has to know which one is canonical. Document it, and prefer to make the old field obviously legacy.
The neighbors
API versioning (/v1, /v2). The standard alternative. Honest and well understood, and the right call when consumers genuinely need a frozen contract for a long time. The cost is standing duplication of logic, tests, and docs. Expand and contract versions the change instead of the API, trading that duplication for a migration you have to actively finish.
Big-bang cutover. Update server and all clients in one coordinated release. Cheapest when it works, because there is no transitional state. It works only when you control every consumer and can deploy them together, and the failure mode is a synchronized rollback at 2am.
Deprecate and hope. Mark the old field deprecated, document the new one, change nothing else. Not really a migration strategy, it is a wish. Useful only as the announcement layer on top of an actual expand and contract.
The discipline underneath
What I like most about the pattern is that it is less a technique than a habit of thought. It forces one question on every change: are you adding something, or replacing something? If you are replacing, then when exactly is it safe to remove the old thing, and have you confirmed every consumer is ready?
The reflex answer is usually "it is a minor change, it will be fine." That reflex is what produces 2am incidents, not exotic race conditions but the mundane assumption that every consumer is running the version you just shipped, which is almost never true. The answer to "when is it safe to remove?" is almost always "once nothing uses it," and that is something you can verify: grep the codebase, watch the metrics, audit deploy by deploy. You never have to trust a synchronized cutover, which is a coordination fiction.
Make the new thing exist before you make the old thing not exist. Everything else is just deciding when the gap between those two steps is safe to close.
References
- Sato, Danilo. "ParallelChange." martinfowler.com
- Stripe. "APIs as infrastructure: future-proofing Stripe with versioning." stripe.com
- PostgreSQL. "ALTER TABLE." postgresql.org
Related essays
The HTTP QUERY Method, Explained
RFC 10008 gives HTTP a method for safe queries with a body. It fills the awkward gap between long GET URLs and POST requests that normally reserved for mutations.