The unsafe migration is the one that changes the schema and application contract in the same deploy. During a rolling release, old and new code run together; your database has to speak both versions for a while.
Short answer: expand first, migrate data and reads/writes while both shapes work, verify, then contract later. This “parallel change” approach is deliberately boring because rollback stays possible. Martin Fowler's parallel-change pattern captures the essential sequence.
Rename full_name without breaking old pods
| Phase | Database | Application |
|---|---|---|
| Expand | Add nullable display_name | Continue reading full_name |
| Dual write | Keep both columns | Write both fields |
| Backfill | Fill old rows in bounded batches | Read new field with old fallback |
| Cut over | Validate parity | Read only display_name |
| Contract | Drop old column in a later release | Remove compatibility code |
UPDATE users
SET display_name = full_name
WHERE id > :last_id AND id <= :next_id
AND display_name IS NULL;
Batching makes the backfill observable and restartable. One enormous UPDATE is how a harmless column rename turns into lock contention, replica lag, or a page at 3am.
Changes that need special suspicion
- New
NOT NULLcolumns: add nullable, backfill, enforce the constraint later. - New indexes: use the database's online/concurrent index capability where available; do not assume DDL is non-blocking.
- Type changes: add a new column and transform gradually when the conversion can fail or changes semantics.
- Destructive drops: treat them as a separate deploy after dashboards show no old-code traffic.
“The migration ran in staging” does not answer whether it blocks a hot production table. Test against production-like volume and inspect the database-specific lock behaviour.
Zero downtime is a compatibility problem first and a SQL problem second. Give yourself a period where either app version can succeed, and the scary rollback becomes a routine rollback.
Cover photo by panumas nikhomkhai on Pexels.
