Rajnish.
Let's talk ↗
← Writing
migrationpythonfintechsystems-designengineering-practice

The Quiet Work of Migrating a Live System

Upgrading a tax filing platform from Python 2.7 to modern Python without breaking active calculations: what technical diagrams leave out, and what actually makes it work.

10 June 20258 min readsystems

Nobody jumps at the chance to migrate a live financial system running on Python 2.7.

You can point to unpatched security vulnerabilities, outdated packages, and incompatibility with modern cloud tooling. Everyone agrees those are real problems, but taking ownership of a multi-month migration where any mistake could corrupt user tax data is intimidating.

When I started on the Taxspanner migration, Python 2.7 had already been unsupported for over two years. Active users were filing tax returns daily, so we could not afford calculation errors.

Here is what made the migration work.

The technical pattern is the easy part

Most backend engineers know the core pattern for this: the strangler fig approach. Keep the legacy service running, build the new service next to it, route traffic in small steps, verify output, and repeat.

That concept is solid, but it leaves out the hardest parts:

  • Which modules should you migrate first?
  • How do you manage two frameworks in one repository without confusion?
  • How do you keep shipping customer features during a long migration?
  • How do you keep a team motivated when daily progress is invisible to users?

Those questions cause more migration failures than Python syntax changes ever do.

Order matters more than speed

When starting out, the instinct is to tackle the messiest, most fragile code first. In practice, that is usually a mistake.

We started with low-risk, isolated modules: background utilities, helper libraries, and internal administrative tools.

Starting small helped for three reasons:

  1. It gave the team space to test our deployment workflow on low-stakes code before touching core tax calculation routines.
  2. It built early momentum. Seeing eight modules cleanly migrated in the first few weeks proved to everyone that the approach worked.
  3. Isolated modules gave us room to make mistakes and adjust our testing tools without endangering customer data.

Setting clear boundaries

For about six months, we maintained two frameworks side by side: Django for legacy routes and FastAPI for new services. Having two ways of doing things in one repo can be confusing if boundaries are blurry.

We set up a simple folder layout and stuck to it:

/services/          # FastAPI (new services and migrated endpoints)
/legacy/            # Django (existing legacy code being moved)
/shared/            # Shared utilities migrated incrementally

New features went into /services/. If someone touched /legacy/, they were either actively migrating that file or patching an urgent production bug.

We also kept a shared migration tracker showing what was finished, what was currently in flight, and what remained. That simple document kept everyone on engineering and product aligned on real progress.

Writing tests before changing code

Legacy systems often lack automated tests on core business logic. The original authors understood how it worked, so tests got deferred. Years later, new engineers have to update code without knowing every subtle edge case.

We adopted a strict rule: before migrating any module, write tests verifying how it currently behaves in production.

Writing tests first added about 30% more time up front, but it saved us from several production issues by catching discrepancies between Python 2 and Python 3 handling before code reached users.

Shadow running for high-stakes paths

For tax calculations and payment submissions, we ran both implementations in parallel on live requests. The legacy service returned the actual response to the user, while the new service processed the same payload in the background, logging its output.

We compared those logs for two weeks. When outputs matched consistently across thousands of real transactions, we cut traffic over to the new engine.

Running dual workloads temporarily increased infrastructure costs, but that was cheap compared to the risk of filing inaccurate tax returns.

What I would do differently next time

  • Include test-writing in initial time estimates from day one. We had to adjust timelines when we realized how much test coverage needed to be written first.
  • Be firmer about keeping new feature work off the legacy stack. For the first couple of months, some developers still added small features to the old Django codebase because it was familiar. Enforcing that all new features lived on the new stack from day one would have saved time.

The real takeaway

Large migrations are as much about team coordination as they are about system architecture.

The technical steps (gradual routing, shadow testing, compatibility shims) are well established. The harder part is keeping developers focused, providing clear guidelines, and making unglamorous maintenance work visible and valued across the company.

Keep Going

The Habits That Make or Break Engineering TeamsWhat I Learned Making an API 7x Faster

Something resonated? I'd love to hear about it.

Let's talk →

← Previous

The Habits That Make or Break Engineering Teams

Next →

What I Learned Making an API 7x Faster