Expand and Contract

Expand and contract, also known as parallel change, 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.”

It exists because an interface change is really two jobs at once. Making the change “requires two thinking modes: implementing the change itself, and then updating all its usages,” and doing both together “can be hard… especially if the change is on a PublishedInterface with multiple or external clients.” The pattern separates them in time.

The three phases

  1. Expand. “You augment the interface to support both the old and the new versions.” Nothing is removed, so “existing clients will continue to consume the old version, and the new changes can be introduced incrementally without affecting them.”
  2. Migrate. “You update all clients using the old version to the new version. This can be done incrementally and, in the case of external clients, this will be the longest phase.”
  3. Contract. “Once all usages have been migrated to the new version, you perform the contract phase to remove the old version and change the interface so that it only supports the new version.”

The property that makes this a delivery pattern rather than a refactoring trick is that “it allows your code to be released in any of these three phases.” Every intermediate state is a shippable system. It “also lowers the risk of change by allowing you to migrate clients and to test the new version incrementally.”

And it is worth using even where you own every caller: “even when you have control over all usages of the interface, following this pattern is still useful because it prevents you from spreading breakage across the entire codebase all at once.”

Applied to a database

For schemas this is the standard method rather than an analogy. Database refactoring is “a key component to evolutionary database design. Most database refactorings follow the parallel change pattern, where the migrate phase is the transition period between the original and the new schema, until all database access code has been updated to work with the new schema.”

The operational recipe is stated plainly elsewhere, and the clause in the middle is the reason to follow it: “the trick is to separate the deployment of schema changes from application upgrades. So first apply a database refactoring to change the schema to support both the new and old version of the application, deploy that, check everything is working fine so you have a rollback point, then deploy the new version of the application. (And when the upgrade has bedded down remove the database support for the old version.)”

Which gives a rule you can enforce in review: never ship a schema change and the code that requires it in the same deployment. Renaming a column then decomposes further than the three phase names suggest, because the migrate phase contains several states that have to be entered in order.

StepSchemaApplicationEntry condition
ExpandAdd the new column alongside the old; nullable or defaultedUnchangedNone — the running version never sees the addition
Write bothUnchangedEvery writer writes both columns; readers still read the old oneEvery writer deployed — including batch jobs, admin tools, and anything outside the main service
BackfillPopulate the new column for existing rowsUnchangedDual writing is live everywhere, and the backfill cannot lose a concurrent write — see below
Switch readsUnchangedReaders use the new column; writers still write bothBackfill complete and verified — the columns agree for every row
Stop writing the oldUnchangedWriters use only the new columnYou have decided not to roll back to a version that reads the old column
ContractDrop the old columnNo reference to it remains anywhereThe previous step is confirmed deployed everywhere. Irreversible

Each extra step exists to close a specific hole, and none of the three below shows up as a test failure.

A writer that was missed desynchronizes the columns. If some writer still updates only the old column while readers have moved to the new one, every row it touches after the backfill has a stale new value and a current old value. There is no error — the reader simply returns what was there before that write. So “every writer” has to mean every writer, and the ones people forget are not the application: a nightly job, a support script, an admin console, a replication process.

The backfill can overwrite concurrent writes, and having fixed every writer does not prevent it. The interleaving is short enough to check: the backfill reads old = 10; a writer then sets both columns to 20 and commits; the backfill writes the 10 it is still holding into the new column, leaving (old, new) = (20, 10). The dual writer did everything correctly — the loss happened inside the backfill. And a transaction around the read and the write does not close it, because at the default isolation level a plain SELECT “sees only data committed before the query began.”

The entry condition for this step therefore needs a concurrency contract, not just deployed writers. Three work, in descending order of simplicity.

  • One statement, batched. For a straight copy, UPDATE t SET new = old WHERE new IS NULL over ranges of the primary key. The value never leaves the database, and PostgreSQL documents why that is enough: a second updater “will wait for the first updating transaction to commit or roll back,” then “will attempt to apply its operation to the updated version of the row,” with the WHERE clause “re-evaluated to see if the updated version of the row still matches the search condition.” So it copies the current value, and rows a dual writer already filled are skipped.
  • Lock, then transform. When the new value cannot be computed in SQL, take the row with SELECT … FOR UPDATE — which returns “the updated version of the row” after waiting — and write it back in the same transaction.
  • Conditional write with retry. UPDATE … WHERE id = ? AND old = ?, using the value you read. Zero rows affected means somebody else got there first; re-read and try again.

Or hand the problem to the database. GitLab’s migration guidance does that for a rename — a regular migration “is used to create a new column with a temporary name along with setting up some triggers to keep data in sync,” with cleanup deferred to a post-deployment migration. If you take that route, state which direction the trigger synchronizes and check that a trigger on a live write wins against a slower backfill writing an older value; otherwise the race has moved rather than closed. Batching and transactions decide how long the backfill runs and what it locks. Whether a write can be lost is decided by whether the value was read inside the same atomic operation, or verified before it lands.

Dual writing is not bounded by “two versions are live.” It is bounded by how long you might still roll back to a version that reads the old column. That is usually longer than the rolling deployment window and it ends when you decide it ends, which is what makes “stop writing the old” a decision rather than tidying up.

The contract step deserves the same care in the other direction. Dropping the column and removing the code that writes it are separate deployments: if the DROP lands while instances that still write both columns are running, those instances fail on every write. GitLab spreads a column drop across three releases — ignore the column, drop it, remove the ignore rule — because “dropping a column is a destructive operation that can’t be rolled back easily.” Their procedure also names a trap worth generalizing: the framework “caches the tables schema when it boots even if the columns are not referenced,” so code that never mentions the column can still break when it vanishes. “Nothing references it” is a claim to verify at runtime rather than infer from a diff.

Two operational notes to finish. A backfill on a large table has its own load and duration, so it belongs in the plan rather than in a footnote — discovering mid-release that it takes four hours is avoidable. And for a large table the intermediate state persists: the same guidance warns that when the first migration has run but the cleanup has not, the system can run that way “for a significant amount of time.” Design the intermediate state to be livable, because it is where you will actually be.

Applied to an API, and to deployment itself

For a remote interface the pattern is presented as “an alternative to using an explicit version in the exposed API” — you can expand the payload on an existing endpoint, or introduce a new endpoint alongside the old one. Where you expand a payload in place, tolerance on the consumer side is what keeps it safe: “following Postel’s Law is a good technique to avoid consumers breaking when the payload is expanded.” Which is a reminder that an additive change is only compatible because consumers ignore what they do not recognize.

A useful variant when the migrate phase will be long: “implement the old method in terms of the new API.” Delegating the old form to the new one “is also a way to break the migrate phase into smaller and safer steps, allowing you to change the internal implementation first before changing the exposed API to clients. This is useful when the migrate phase is longer so you don’t have to maintain two separate implementations.” One behaviour, two entry points, instead of two implementations drifting apart.

The pattern also explains the deployment techniques rather than merely resembling them: “canary releases and BlueGreenDeployment are applications of the parallel change pattern where you have both old and new versions of the code deployed side by side, and you incrementally migrate users from one version to another.” Progressive deployment is parallel change at the level of running processes; expand and contract is the same idea at the level of the data and interfaces those processes share. That is why one does not work without the other — see blue-green deployment and canary deployment.

The phase that does not happen

Expand is satisfying, migrate is work, and contract is nobody’s priority — so the predictable failure of this pattern is a system full of half-finished parallel changes. Every one of them is a shape the data can still take, so the next change has to be compatible with three historical forms of the same field, and the cost compounds silently.

Three things keep contraction happening. Create the contraction task when you create the expansion, with an owner. Measure usage of the old form, since you cannot contract on hope — for a published interface this is the same measurement deprecation depends on. And set the migrate window by who the consumers are: internal callers you deploy yourself are days, and external clients are quarters, which the source acknowledges as “the longest phase.”

Worth knowing which changes need this at all: additive ones usually do not. Whether a given change is incompatible in the first place is the judgment in breaking change, and for event and analytical schemas the governing choice is the compatibility direction, covered under schema evolution and compatibility. How the three phases interact with progressive deployment and rollback is worked through in Two Versions at Once.

References: Danilo Sato, Parallel Change, martinfowler.com (2014); Martin Fowler, Blue Green Deployment (2010, updated 2015); GitLab, Avoiding downtime in migrations; PostgreSQL documentation, Transaction Isolation. All checked September 2026.


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.