Both Versions Are Running
The mental model most people carry into a deploy is a switch: at some instant, the old version stops and the new one starts. Almost no real deployment works that way. A rolling update replaces instances a few at a time, so for minutes — sometimes much longer — some fraction of traffic hits the old code and the rest hits the new. Even a fast blue-green cutover leaves in-flight requests, open connections, and queued messages that were produced by one version and will be consumed by the other. The switch model is convenient, and it quietly hides the period that actually causes trouble: the window where both versions are running against the same database, the same queue, and the same caches.
Inside that window, every change is implicitly a compatibility question, whether or not anyone treated it as one. If the new version writes a field the old version doesn’t understand, the old instances still serving traffic have to do something reasonable with records they can’t fully read. If the new version stops writing a field the old one still depends on, the old instances start seeing records that look, from their perspective, malformed or incomplete. A message enqueued by the new code gets picked up by an old consumer, or the reverse. None of this shows up in a test suite that runs one version against a clean database, because the test suite never has both versions present at once — the exact condition every deploy creates by construction.
This is also what makes rollback less of a clean escape hatch than it sounds. Rolling back re-enters the same mixed window from the other direction, with an added complication: the data now contains whatever the new version wrote while it was live. Going forward, the old code has to tolerate new-format records it never anticipated. That’s why “we can always roll back” is a weaker safety net than it feels like — the rollback is only safe if the old version can handle the state the new version already produced, which is a property you have to design in ahead of time, not a property rollbacks come with for free. The previous piece here made the point that a rollback doesn’t undo consequences; this is the sharper version of it, where the consequences are sitting in the database the old code is about to read.
The practical discipline is to make every change survivable in both directions, which usually means splitting changes that feel atomic into stages that are individually safe. Add the new field and write it before anything reads it. Read it only after every instance is writing it. Stop reading the old field before you stop writing it, and remove it only once nothing has read it for long enough to be sure. Each step is deployable and reversible on its own, because at every point both versions can coexist. It feels slower than a single change that flips everything at once, and it is — but the alternative isn’t a faster deploy, it’s a deploy whose safety depends on nothing going wrong during the window, plus a rollback path that may not actually work when you need it.
The underlying shift is to stop thinking of a version as something that replaces another and start thinking of it as something that has to interoperate with its neighbors — the version before it and the version after. That framing costs a little discipline on the way in and pays for itself the first time a deploy goes wrong at 2 a.m. and rolling back turns out to be genuinely safe rather than a hopeful guess. Deploys aren’t instantaneous, and the window where both versions are live isn’t an edge case. It’s the normal path, and it deserves to be designed for rather than survived.