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.
-- 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;# 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: strRollback 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.