Parity before cutover
We run a service that several other teams and services depend on. For years it existed twice: the production version on Python 2, deployed to on-prem servers, and a modernised Python 3 version on a separate branch, waiting for a cloud migration that would finally let it replace the original.
Plenty of legacy upgrades look like this. The end state is obvious - new language version, new infrastructure, old system gone - so teams aim straight at it. The work happens on a long-lived branch or a "v2" rewrite, and the plan is to do the hard work away from production, then cut over once at the end.
The rewrite that never lands
That plan rarely delivers what it promises. The risk doesn't go away while the new version sits unreleased, it accumulates.
Every urgent fix still has to land on the old version, and then be ported to the new one - or forgotten. Every difference in behaviour between the two, intentional or not, is a bug waiting to be discovered by a downstream team after cutover, when it's hardest to attribute.
Long-lived branches feel efficient because they bundle everything into one event. What they actually do is concentrate risk at a single point in time and delay the feedback that would have told you something was wrong.
Going backwards to ship forwards
When we picked up the migration, the instinct was to push for the end state: get the modern version running in the cloud and start moving consumers over. I advocated for a step backwards first: get the modern version running in production on the old infrastructure, so that old and new were the same code before anything else moved.
The legacy hosts ran an operating system old enough that its Python support topped out at 3.6, and the modern version targeted 3.7. We could have worked around it - built Python from source, layered containers onto hosts that were never designed for them - but every workaround added surface area to a migration that already had plenty.
Instead, we downgraded the service to 3.6 and restored the on-prem deployment configuration, with settings selected per environment at runtime. One codebase, deployable to both the legacy hosts and Kubernetes.
A version downgrade looks like negative progress. It's what made the rest of the migration straightforward.
One variable at a time
With parity in place, every release went to both environments. Differences in behaviour between old and new surfaced immediately, in small increments, instead of all at once at cutover. The years of divergence between the two versions were paid down while the old infrastructure was still there as a fallback.
By the time we cut over to the cloud, it was a pure infrastructure change. We weren't asking "did the platform migration work?" and "did the rewrite work?" at the same time - if something broke, we knew it was the platform, because the code was identical. The cutover was boring, and boring is exactly what you want. It's the same principle behind upgrading React incrementally across micro-frontends: sequence the variables so each release changes one thing.
Shipping the path, not the end state
With the migration done, the Python upgrade that had been stuck for years shipped as a routine release - the old hosts' version ceiling was gone, and there was only one codebase to upgrade. The end state arrived faster because we stopped aiming directly at it.
If a branch has been open for months, or a rewrite is waiting on other teams to migrate, that's usually a sign the change is trying to do too much in one go. Look for an intermediate state that's releasable now, even if it looks like a step backwards. The end state was never the hard part - the hard part is the path, and the safest path is one you can release one step at a time.