17

Expand — Migrate — Contract

#
  • pattern
  • established
  • fragment

Conflicts with: Canary ↔ schema migrations

References: Pydantic — Fields

The only correct protocol for changing a schema or an API under continuous deployment. Never “rename column” — always a sequence in which, at every step, old and new code run side by side.

The full cycle: 1) expand — a nullable column and compatible code; 2) dual write; 3) batched backfill with a checkpoint; 4) reconciliation of old and new values; 5) switching reads; 6) an observation period; 7) NOT NULL and the constraint; 8) contract — removing the old.

Code example (fragment)
fragment
SQL
-- 1. EXPAND: a nullable column with no default is a fast metadata-only change.
--    NOT NULL and a backfill at this step would take ACCESS EXCLUSIVE on the table.
ALTER TABLE users ADD COLUMN email_normalized TEXT;

-- 2. DUAL-WRITE is the application's job: both fields written in one transaction.

-- 3. BACKFILL in batches with a checkpoint, not a single UPDATE:
--    one big UPDATE = long locks, WAL volume blowup, bloat,
--    replica lag, and no way to interrupt the job.
WITH batch AS (
    SELECT id FROM users
    WHERE email_normalized IS NULL AND id > :checkpoint
    ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE users u SET email_normalized = lower(u.email)
FROM batch b WHERE u.id = b.id
RETURNING u.id;   -- the max returned id becomes the new :checkpoint

-- 4. VERIFY: there must be no mismatches; this gates the move to step 5
SELECT count(*) FROM users
WHERE email_normalized IS DISTINCT FROM lower(email);

-- 5-6. Reads switch via a flag (72); dual write stays on for the whole observation period.

-- 7. The constraint is added in two steps to avoid holding a long lock
ALTER TABLE users ADD CONSTRAINT users_email_norm_not_null
    CHECK (email_normalized IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_norm_not_null;

-- 8. CONTRACT: only once no live deployment still reads the old field
ALTER TABLE users DROP COLUMN email;
Python · pydantic v2
# Same for API fields: the deprecated field lives alongside; return both.
# The marker goes on the field itself, so it reaches JSON Schema and OpenAPI
class UserOut(BaseModel):
    email: str = Field(deprecated="Use email_normalized")
    email_normalized: str

Rollback differs at each step: before step 5 it is enough to stop writing to the new field; after it, switch reads back with the flag. After step 8 there is no rollback — it is a one-way door (20). That is why an observation period is needed between 5 and 8, measured in weeks of deployments, not in hours.

Last technical review: 2026-08-04 · Reference · 112 principles