You need to rename user_name to username. The migration is one line. You deploy it alongside the code change and, for the ninety seconds your rolling deploy takes, half your instances are running old code querying a column that no longer exists.
The window is short. It is also exactly when every request against those instances fails. And rolling back is worse, because now the new code is querying a column you just renamed away.
The fix is not a better migration tool. It is accepting that old and new code coexist during every deploy, and designing the change so both work.
What changed in 2026
- Rolling deploys became universal. Container orchestration made gradual rollout the default, which made the coexistence window unavoidable rather than exceptional.
- Migration tooling grew safety checks. More frameworks warn on operations known to lock or break compatibility.
- Online DDL improved. Databases got better at adding columns and building indexes without long locks, removing one class of problem while leaving the compatibility one.
- The pattern became standard practice. Expand-contract stopped being an advanced technique and became how competent teams do schema changes.
The three phases
Expand. Add the new structure without removing the old. Add the new column, nullable, with no constraint that existing writes would violate. Deploy code that writes to both columns and reads from the old one. Nothing breaks because the old column is untouched and the new one is optional.
Migrate. Backfill the new column from the old for existing rows, in batches so you do not hold a long transaction — see MVCC explained for why a long transaction is its own problem. Then deploy code that reads from the new column while still writing both. At this point the old column is written and unread.
Contract. Once you are confident nothing reads or writes the old column, deploy code that only uses the new one, then drop the old column in a final migration.
| Phase |
Schema |
Code writes |
Code reads |
| Start |
old |
old |
old |
| Expand |
old + new |
both |
old |
| Migrate |
old + new |
both |
new |
| Contract |
new |
new |
new |
At every point between phases, both the previous and current version of the code work against the current schema. That is the property that makes rolling deploys safe, and it is the whole design goal.
Each phase is a separate deploy
The most common way this goes wrong is compressing it. Adding the column and switching reads in one deploy means, during the rollout, some instances read a column that has not been backfilled yet.
Three deploys, each fully rolled out and verified before the next begins. It is slower and it is the point — the safety comes from the gaps, not the phases.
The same reasoning applies to rollback. Each phase must be independently reversible. If phase two goes wrong, rolling back to phase one must work, which it does precisely because both columns still exist and both are still written.
Constraints deserve their own care. Adding a NOT NULL constraint to a column old code does not populate breaks the old code's inserts. Add the column nullable, backfill, get all writers writing it, and only then add the constraint — a fourth step in practice.
Knowing when to contract
Contracting is the step people rush, and the one where the damage is unrecoverable — you dropped the column.
Verify rather than assume. Reading the code is not sufficient, because raw SQL, reporting tools, BI connections, and scripts nobody remembers all touch the database. Better signals:
Monitor actual usage. Many databases can report on column access or let you log queries touching it. Zero references over a meaningful period is evidence.
Wait longer than feels necessary. A weekly report referencing the old column will not show up in a day of monitoring. Let a full business cycle pass.
Stop writing before dropping. An intermediate deploy that stops writing the old column, left running for a period, surfaces anything still depending on it while the column still exists and can be restored to service.
Common mistakes
- One deploy for everything. Reintroduces exactly the breakage the pattern prevents.
- Adding NOT NULL too early. Breaks writes from code that does not know the column.
- Backfilling in one transaction. Long-running transaction, held locks, blocked cleanup.
- Contracting on a code read. Raw queries and reporting tools are invisible to grep.
- No rollback plan per phase. Each phase must independently reverse.
- Renaming rather than expand-contract. A rename is inherently a breaking change.
- Ignoring lock behaviour. Some DDL takes locks that block writes; check what your engine does for the specific operation.
FAQ
Is this needed for adding a column?
No. Adding a nullable column with no default is additive and safe — old code ignores it. The pattern is for changes that alter or remove existing structure.
What about changing a column type?
Same three phases with a new column of the new type, dual writes, backfill, switch reads, drop. In-place type changes frequently rewrite the whole table and take a lock.
How long between phases?
Long enough to be confident, which is longer for the contract step than the others. Hours between expand and migrate is often fine; days to weeks before contracting is reasonable for anything with periodic consumers.
Does this apply to APIs too?
Exactly the same reasoning at a different layer — add the new field, support both, migrate consumers, remove the old. Any interface with independently-deployed clients has this problem.
Where to go next
For the deployment mechanics this pattern assumes, read zero downtime deployment. For why long backfill transactions cause trouble, MVCC explained, and for testing migrations against a real database, testcontainers.