Expand and Contract

Expand and contract, also called parallel change, divides an incompatible interface change into three phases: add support for the new form, migrate consumers, and remove the old form. Danilo Sato describes the pattern as a way to release intermediate states while preserving compatibility.

Changing an interface and updating all its callers are separate jobs. Keeping both forms available allows their rollout schedules to differ, which is especially useful for external clients.

The three phases

  1. Expand. Add the new form while preserving the behavior existing clients rely on. An addition is safe only if those clients tolerate it.
  2. Migrate. Move callers to the new form incrementally. External clients can make this phase long because their release schedules are outside the producerโ€™s control.
  3. Contract. Remove the old form after confirming that supported callers no longer depend on it and that the required rollback window has closed.

Design each intermediate state to be releasable. This allows testing and migration to proceed in increments while the old interface remains available.

The separation can also help when one team owns every caller: it avoids spreading a breaking edit across the whole codebase before anything can run again.

Applied to a database

For a database change, the migration phase is the period in which application versions must coexist with the evolving schema. Both forms need to remain valid until their consumers have moved.

Martin Fowlerโ€™s deployment example applies a schema change that supports both application versions, checks that intermediate state, and then deploys the new application. Support for the old form is removed only after the upgrade has settled.

Preserve that ordering and a verified stopping point in the release process. Whether a tool labels the steps one deployment or several matters less than ensuring the schema is ready before dependent code starts, and that failure can stop progress safely. A column rename needs several intermediate states:

StepSchemaApplicationEntry condition
ExpandAdd the new column alongside the old; nullable or defaultedUnchangedExisting readers and writers tolerate the addition; DDL locks and defaults have been assessed
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 addresses a failure mode that ordinary happy-path tests can miss. Test the relevant writer coverage, concurrency, and rollback conditions explicitly.

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 stays inside one database statement. At PostgreSQL Read Committed isolation, an updater waits for a concurrent updater to finish, then applies its operation to the updated row if the re-evaluated condition still matches. The backfill therefore copies the current value and skips rows whose new column the dual writer has already filled.
  • 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 bounded retry. For a transformation determined only by old, PostgreSQL can compare the read value using UPDATE t SET new = $1 WHERE id = $2 AND old IS NOT DISTINCT FROM $3. The NULL-safe comparison accepts an unchanged NULL value. Include every input the transformation depends on, or compare a suitable row version. If no row is updated, reread and distinguish deletion, completed work, and a changed input before retrying. Bound retries and record unresolved rows. If NULL is a valid target value, use separate progress tracking rather than treating NULL alone as unfinished work.

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

An API can keep an old endpoint while adding a new one, or add a form to an existing endpoint when clients tolerate it. Adding fields in place requires checking consumer behavior; an old strict reader can reject an expanded payload.

Where the semantics permit it, implement the old method through the new implementation. This keeps one behavior behind two entry points during migration and reduces the risk of maintaining divergent implementations.

Sato also describes canary and blue-green deployment as parallel change applied to running versions: both exist while users move between them. When those versions share data or interfaces, their compatibility must survive the overlap. 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.

Create the contraction task with an owner when expansion begins. Measure use of the old form, as deprecation requires, and choose a migration window from consumer release schedules, validation work, and the support commitment. Internal ownership can shorten coordination; external clients may need much longer, but neither duration follows from the label alone.

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


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.