A data change can look trivial in code and still be dangerous in a running system. Renaming a field, splitting one value into two, or changing a message shape may require several application versions, background jobs, and consumers to coexist while the change is in progress.
The risky assumption is that the whole system changes at once. In practice, deployments take time, workers may finish old jobs after new code is live, and independently deployed consumers may upgrade later. If one release removes the old shape while something still depends on it, a locally correct change becomes a system failure.
Expand and contract is a way to make these changes in compatible stages. First, expand the system so old and new forms can coexist. Next, move data and callers toward the new form. Finally, contract the system by removing the old form after evidence shows it is no longer needed.
This article explains the mental model, a small example, the important ordering rules, and the cases where a simpler migration is enough.
Treat migration as a period of coexistence
The core idea is simple: during a migration, assume old and new software may run at the same time.
Suppose an order record currently stores one field:
customer_name = "Amina Yusuf"A new requirement needs separate values:
given_name = "Amina"
family_name = "Yusuf"A tempting migration is:
- remove
customer_name; - add the two new fields;
- deploy code that uses them.
That sequence works only if every reader and writer switches atomically. If an old worker runs between steps, it may still expect customer_name. If new code runs before old records are converted, it may find the new fields empty.
Expand and contract changes the question from “How do we replace the field?” to “How do we keep both generations correct while responsibility moves from one to the other?”
That leads to three phases:
EXPAND MIGRATE CONTRACT
old old + new new
| | |
v v v
add compatible move reads, remove old
new shape writes, data shapeEach boundary should leave the system in a usable state. That property is more important than the exact number of deployments.
Expand without breaking existing users
The expand phase introduces the new representation without invalidating the old one.
For the name example, the stored shape might temporarily become:
customer_name = "Amina Yusuf"
given_name = null
family_name = nullOld code can continue reading customer_name. New code now has somewhere to store the replacement representation.
The important property is compatibility: introducing the new shape does not require every existing user to understand it immediately.
Keep reads compatible during the transition
New readers often need to handle both representations while old data still exists. A simplified read can express the transition explicitly:
function display_name(order):
if order.given_name is not null:
return join_name(order.given_name, order.family_name)
return order.customer_nameThe fallback is temporary migration logic. It lets new code read records that have not yet moved.
This is different from permanently supporting two equivalent representations. The migration should have a defined destination. Otherwise every future change must preserve both forms and decide which one is authoritative.
Decide how writes behave
Writers require an explicit policy too. Common transition policies include:
- write the old form and derive the new form;
- write the new form and derive the old form;
- temporarily write both from the same validated input.
The correct choice depends on the system. The key is to avoid two independent sources of truth.
For example, if application code receives structured names, it might temporarily produce both representations from that one input:
order.given_name = input.given_name
order.family_name = input.family_name
order.customer_name = format_name(input.given_name, input.family_name)This keeps old readers working while new records are immediately usable by new readers.
Dual writing has a cost: the two forms can diverge if separate code paths update them differently. Keep the compatibility period bounded, derive both values from one decision where possible, and monitor disagreements when the values can be compared.
Migrate existing data separately from changing code
After compatible code exists, old records can move to the new representation.
For a large data set, this is often safer as a separate operation rather than part of an application startup or request path. A background migration can process records in bounded batches:
for each batch of orders:
for each order with no given_name:
parsed = split_legacy_name(order.customer_name)
save_new_name_fields(order, parsed)This example is deliberately simplified. Real name data may not be safely divisible into “given” and “family” components. If the old representation does not contain enough information to construct the new one correctly, the migration needs a different policy: preserve the original value, collect missing information, or model the domain differently. A migration cannot recover information that was never stored.
That boundary condition illustrates a general rule: schema compatibility and semantic correctness are separate problems. Expand and contract can make the rollout safe, but it cannot make an invalid transformation valid.
Make migration work restartable
Long migrations can fail halfway through because of process restarts, deployment interruptions, or bad individual records. A useful migration therefore knows how to resume.
One approach is to select only records that have not been converted:
where given_name is nullThen rerunning the job skips completed work. This is appropriate only when null unambiguously means “not migrated.” If null is a legitimate final value, use another marker or a transformation that can safely be repeated.
The practical requirement is not that every migration use the same mechanism. It is that interruption should have a defined recovery path.
Move readers before removing the old representation
Once existing data is converted and new writes populate the new form, readers can stop depending on the old field.
A useful sequence is:
1. Add new representation.
2. Deploy compatible readers and writers.
3. Migrate existing data.
4. Verify new data is populated correctly.
5. Switch all readers to the new representation.
6. Stop producing the old representation.
7. Verify nothing still uses the old representation.
8. Remove it.The exact middle steps can vary. The ordering constraint is what matters: do not remove an old capability while a live participant can still require it.
This applies beyond database fields. The same reasoning can help with event payloads, configuration keys, serialized files, shared library interfaces, and other contracts used by components that do not change simultaneously.
Contract only after evidence replaces assumption
The contract phase removes compatibility code and the obsolete representation.
This is where migrations often stall. Teams add the new path but leave the old one indefinitely because nobody can prove it is unused. The result is permanent dual behavior.
Before removal, look for evidence appropriate to the boundary:
- all deployed application versions read the new form;
- background workers created by old versions have drained;
- migration counts show no records remain in the old-only state;
- metrics or logs show no fallback reads over a meaningful observation period;
- known external consumers have moved when the contract crosses team boundaries.
No single signal is universally sufficient. A service with one deployment unit needs less coordination than a public contract used by unknown clients.
Once the old form is genuinely unused, remove the fallback, dual-write code, migration-only instrumentation, and old field or contract. Cleanup is part of the migration, not optional polish. Leaving both paths makes future developers maintain rules whose purpose has expired.
Separate compatibility from correctness
An expand-and-contract rollout can remain available while still producing wrong data. It helps to review two different questions.
First, compatibility: can every version that may be live understand the data it receives?
Second, correctness: does the new representation preserve the intended meaning?
Consider splitting price into amount and currency. Adding both fields may be structurally compatible, but filling every historical row with currency = "USD" is correct only if the old system actually guaranteed that interpretation.
Treat migration assumptions as engineering assumptions that need evidence. If the old value is ambiguous, preserve the ambiguity or define an explicit business rule rather than inventing precision during conversion.
Watch for the main failure modes
Removing the old shape too early
This is the central sequencing failure. A migration succeeds in a test environment where one version runs at a time, then fails during a rolling deployment because old instances still read the removed field.
Design the overlap period before changing the contract.
Allowing both forms to become authoritative
If one code path updates customer_name while another independently updates given_name, the system can contain contradictory values.
Choose one source of truth for each phase and derive compatibility data from it where practical.
Running an irreversible transformation without a recovery plan
Some transformations lose information. Dropping a field immediately after converting it removes the easiest way to diagnose or redo a bad migration.
Delay destructive cleanup until the new representation has been verified. For high-risk transformations, decide beforehand how you would recover from incorrect conversion.
Forgetting slow consumers
A database migration may coordinate only application instances, while an event contract may have consumers owned by other teams or organizations. The safe overlap period depends on who controls deployment.
If you cannot know when all consumers have migrated, permanent backward compatibility or explicit contract versioning may be more appropriate than a short expand-and-contract cycle.
Never contracting
Temporary compatibility code has a carrying cost. It creates more branches, more tests, and more possible states.
Give the old path an observable removal condition when the migration begins. “Remove when fallback reads are zero for seven days” is actionable; “remove later” is not.
Use the technique where change cannot be atomic
Expand and contract is valuable when a contract is shared across independently timed operations: rolling deployments, background processing, large data migrations, or separately deployed consumers.
It is often unnecessary when the entire change is genuinely atomic. A small offline tool that owns its file format and rewrites every file before restarting may be simpler to change directly. A development-only data set that can be discarded and recreated does not need production migration machinery.
The technique also does not replace domain analysis. If the new model requires information the old model never captured, compatibility staging cannot manufacture that information. Solve the modeling problem first, then choose a rollout strategy.
Keep one invariant through every phase
A useful way to review an expand-and-contract plan is to ask one question at each step:
If old and new participants coexist right now, can both still perform their required work correctly?
During expansion, the answer comes from adding without removing. During migration, it comes from supporting both representations while data and callers move. During contraction, it comes from evidence that the old representation has no remaining users.
That is the reusable mental model. Do not treat a shared data change as one replacement operation when the system cannot change atomically. Create a compatible intermediate state, move responsibility deliberately, verify the move, and only then remove what became obsolete.