There’s an email everyone over a certain age has sent: “The service will be unavailable this Sunday between 2am and 4am for scheduled maintenance.” For a long time that was just how software worked. You took the system down, ran the migration, brought it back up, and hoped the whole thing fit inside the window. We tolerated it because the alternative looked like magic and the cost of the outage — a few sleepy users, a Sunday morning — felt small.
The tolerance has evaporated. Users are global, so there is no 2am. Systems are chained together, so your maintenance window is somebody else’s incident. And somewhere along the way “we’ll just take it down” stopped being a neutral default and started being a decision you have to justify. Which raises the obvious question, and the one most teams get wrong: how do you change a running system without stopping it?
Zero-downtime is not a deploy button
The common mistake is to treat it as a deployment-tooling problem. Buy blue-green deploys, or rolling updates, or a fancy orchestrator, and downtime goes away. Except it doesn’t, and the moment you discover this is the moment your zero-downtime deploy strategy meets a schema change.
Blue-green works by running the old version and the new version at the same time and cutting traffic over once the new one is healthy. Rolling updates do the same thing more gradually. Both share one non-negotiable assumption: the old and new versions can coexist. They must talk to the same database, consume the same messages, and serve the same clients — simultaneously, for the duration of the rollout. If your new version needs a database column that the old version’s code doesn’t know about, or drops one the old version still writes to, your two “coexisting” versions can’t actually coexist, and your clever deploy strategy just took the site down in a more expensive way.
So zero-downtime isn’t a feature you enable. It’s a property of the change itself, and you design for it — or you don’t get it, whatever your pipeline claims.
Expand, migrate, contract
The technique that makes changes coexistable is old and has several names — parallel change, expand/contract, the expand-and-contract migration. The idea is that you never make a breaking change in a single step. You break it into a sequence of steps, each one of which is independently deployable and backwards-compatible, and you spread them across multiple releases.
Take the smallest example that still hurts: renaming a database column from email to
email_address. The one-line version — ALTER TABLE ... RENAME COLUMN — is a breaking change, because
the instant it runs, the currently-deployed code that still says email starts throwing. Here’s the
same rename with the downtime designed out:
- Expand. Add the new
email_addresscolumn alongside the old one. Deploy code that writes to both and still reads from the oldemail. Nothing has broken — old code and new code both work, because both columns exist and both are being kept current. - Migrate. Backfill
email_addressfromemailfor the existing rows. Now both columns hold the truth for every record. This step touches data, not behaviour. - Contract, part one. Deploy code that reads from
email_address. The old column is still there and still being written, so a rollback is a redeploy, not a recovery. - Contract, part two. Stop writing to
email. Wait until you’re confident. Then, and only then, drop the column.
What was one risky, irreversible statement is now four boring, reversible ones. At no point do two running versions disagree about the shape of the world. That’s the whole trick: you carry both worlds — old and new — for as long as it takes to move everyone across, then retire the old one once nobody’s looking at it.
The strategy and the schema are the same discipline
Notice that expand/contract is exactly what your blue-green deploy needed and couldn’t provide on its own. The deploy tooling gives you two running versions; expand/contract guarantees those two versions can share a database without lying to each other. They’re not two techniques, they’re two halves of one: the deploy strategy handles the code being in two states at once, and the migration strategy handles the data and contracts being in two states at once. Neither works without the other.
And it generalises past databases. A message contract evolves the same way — add the new field before anyone reads it, populate it, migrate consumers, retire the old field — which is the same expand/ contract move I wrote about at the contract level in contract testing. An API version, a config format, a file layout: same shape every time. Add the new alongside the old, move everyone over, remove the old.
The price, and when not to pay it
None of this is free, and pretending otherwise is how you end up resenting it. A one-line rename became a four-step, multi-release dance. For a stretch you’re running two columns, two code paths, and a backfill job, and you’re carrying the cognitive load of “which world are we in right now?” across several deploys that might be days apart. Expand/contract trades engineering effort and temporary duplication for continuous availability. That trade is only worth making when availability is actually worth that much.
Which means, as ever, it depends on what you’re optimising for. A payments platform or a global API earns the full ceremony every time; the cost of the outage dwarfs the cost of the dance. An internal admin tool with a happy user base and a genuine quiet hour does not — a two-minute maintenance window is cheaper, simpler, and easier to reason about than a four-step migration, and insisting on zero-downtime there is just ritual. Reach for a maintenance window when a maintenance window is honestly fine. Reach for expand/contract when it isn’t.
There’s a reversibility angle too, and it’s the part I’d defend hardest. The big-bang migration is a
one-way door: once you’ve run the destructive ALTER and the old
code is gone, getting back is a restore-from-backup and a bad morning. Every step of expand/contract is
a two-way door — you kept the old column, so rollback is just
redeploying the previous version. You’re not only buying uptime; you’re buying the ability to change
your mind at each step, which on anything large is worth as much as the uptime itself. I once helped
move an entire system between hosting environments with barely any downtime, and the reason it worked
wasn’t a heroic cutover — it was that we stood the new environment up alongside the old, ran them in
parallel, shifted traffic across, and decommissioned the old one only once the new one had proven
itself. Expand/contract, at the scale of infrastructure. Same discipline, bigger blast radius.
The one-line version
Zero-downtime is designed, not deployed. Blue-green and rolling updates only work if the old and new versions can coexist, and expand/contract is what makes them coexist: never a breaking change in one step, always add-migrate-remove across several backwards-compatible ones. The cost is real — duplication and discipline while you carry two worlds — so spend it where availability earns it and take the maintenance window where it doesn’t. The bonus, everywhere you do use it, is that every step is reversible, which on a big change is worth as much as the uptime.