Chapter 16 · Schema Evolution, Migrations, and Compatibility

Expand-and-Contract Migrations

Apply expand-and-contract migrations to rename, split, merge, or reshape schema elements without requiring risky synchronized cutovers.

Beginner75–105 minutesExpand-and-contract migrationsLast reviewed: August 2026

Learning outcomes

Expand-and-contract turns a risky one-step schema change into a sequence of compatible states. First expand the schema so old and new forms coexist. Then migrate applications and data. Finally contract by removing the obsolete structure.

01

Apply expand-and-contract to renames.

02

Use it for table splits and relationship changes.

03

Design dual-read/dual-write periods safely.

04

Define clear exit criteria before contraction.

The three phases

model · example
EXPAND  add new structureMIGRATE  new code + backfill + validationCONTRACT  remove old structure

Example: rename customer_id to account_id

Unsafe:

sql · example
ALTER TABLE work_orderRENAME COLUMN customer_id TO account_id;

This assumes every consumer changes at the same instant.

Expand

sql · example
ALTER TABLE work_orderADD COLUMN account_id bigint NULL;

Add index/FK as appropriate, initially in a way that does not break old writes.

Migrate writers

New code writes both:

model · example
customer_id = 17account_id  = 17

or writes the new field while a temporary bridge synchronizes the old one.

Backfill

sql · example
UPDATE work_orderSET account_id = customer_idWHERE account_id IS NULL;

For large tables, do this in controlled batches rather than one giant transaction.

Migrate readers

New code reads account_id, optionally falling back to customer_id during the transition.

Validate

sql · example
SELECT COUNT(*)FROM work_orderWHERE account_id IS DISTINCT FROM customer_id;

Expected result: zero before contraction.

Contract

After old applications are gone and data is validated:

sql · example
ALTER TABLE work_orderDROP COLUMN customer_id;

Exit criteria are essential

  • no old application version remains;
  • no legacy query/report uses old field;
  • backfill complete;
  • consistency check passes;
  • new constraint validated;
  • rollback strategy reviewed.
Do not contract by calendar alone

Remove old structure when evidence shows it is unused and the new path is complete.

Splitting a table

Suppose Asset currently stores owner fields directly but new design needs history:

model · example
Asset.current_customer_id

target:

model · example
AssetOwnership(asset_id, customer_id, valid_from, valid_to)

Expand for table split

  1. create AssetOwnership;
  2. keep current_customer_id;
  3. deploy writes that create/update ownership history;
  4. backfill current ownership rows;
  5. migrate readers;
  6. decide whether current_customer_id remains as derived shortcut or is removed.

Changing one-to-one into one-to-many

Old:

model · example
WorkOrder.primary_technician_id

New:

model · example
WorkOrderAssignment(  work_order_id,  technician_id,  role_code)

Expand by adding association rows while keeping old field until all readers/writers move.

Changing data type

Changing text to bigint in-place may lock or fail on bad data. Expand:

model · example
old_code_textnew_code_bigint

then transform, validate, migrate reads/writes, and contract.

Changing units

Rename alone is not enough if semantics change:

model · example
pressure_psi -> pressure_kpa

The migration needs a deterministic conversion and validation, and old/new code must not interpret the same number using different units.

Changing enum/reference semantics

If one status splits into two:

model · example
closed -> completed / cancelled

backfill requires a business rule for classifying historical rows, not merely DDL.

Dual-write risks

Dual writes can fail partially. Protect them by:

  • same database transaction;
  • outbox/idempotent projection when cross-system;
  • reconciliation queries;
  • short-lived transition periods.

Migration flags

Feature flags can control:

model · example
read_oldread_newwrite_oldwrite_new

This supports gradual rollout, but too many combinations can become hard to reason about. Keep migration states explicit.

WorkshopHub migration plan

ChangeExpandContract
Rename Customer conceptAdd account_id / compatibility viewDrop customer_id later
Add ownership historyCreate AssetOwnership + dual updateRemove old owner field if desired
Split status metadataAdd reference relation/new FKRemove legacy text status

Practice: table split

Split address fields

Customer currently has street/city/postal columns. New design introduces Address because customers can have many addresses. How do you migrate safely?

Review answer

Create Address and CustomerAddress first, backfill existing customer addresses, deploy code that writes/reads new structures while preserving old compatibility, validate coverage, retire old readers, and only then remove legacy address columns.

Summary and next lesson

Expand-and-contract replaces synchronized schema cutovers with compatible intermediate states. The next lesson focuses on the operationally hardest part of many migrations: backfilling and validating large production tables without downtime.

References

  • Pramod Sadalage and Scott Ambler, Refactoring Databases.
  • Martin Fowler, parallel change and evolutionary architecture writings.
  • Database vendor documentation on online schema changes.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.