All posts
Engineering / Case Study

Migrating 80,000 lines of Python 2 with Pylon-Migrate

Abstract before-and-after module migration visualization, old amber shapes transforming to clean cyan shapes

Python 2 reached end of life in January 2020. Six years later, a surprising number of production codebases still run on it. The reasons are usually practical rather than ignorant: the migration is a large project with an uncertain timeline, and there are more urgent things on the backlog. The risk of touching a stable service is high, and the cost of the migration is paid entirely by the engineering team with no visible user-facing benefit.

This is a case study of a migration we supported on an analytics pipeline service: approximately 80,000 lines of Python 2.7, about 240 modules, running batch jobs that produced business reports for a growing media company. We are writing this to describe what Pylon-Migrate actually did, where it struggled, and what the team had to do themselves.

The starting point

The service had been running since 2014. The original authors had left the company, and the current team of four data engineers maintained it without a strong understanding of every corner of the codebase. There were 1,200 tests, but coverage was uneven: the main report-generation paths were well covered, and the data ingestion utilities were barely covered at all.

The team had attempted a manual migration once in 2021. They ran 2to3 on the entire codebase, committed the result, and found that about 30% of the tests now failed due to unicode/bytes issues that 2to3 did not handle correctly. They reverted and shelved the project.

The reason 2to3 alone does not work for a codebase like this: it is a purely syntactic transformer. It handles the things you can fix by looking at a single expression: print statements, dict.keys() returning a list, unicode literals. It cannot handle the places where Python 2's implicit bytes/str conflation was being depended on intentionally. For a codebase that ingests arbitrary text from web sources and does string operations on it, those places are numerous and subtle.

How Pylon-Migrate approached the module order

Before writing a single line of migrated code, Pylon-Migrate builds a dependency graph of the entire service and establishes a migration order. The rule is: leaf modules first. A leaf module is one that imports from the standard library and from third-party packages but not from other internal modules of the service. Migrating leaves first means that by the time you migrate a higher-level module, all of its internal dependencies are already on Python 3, and you only need to reason about the Python 3 semantics of those dependencies, not their Python 2 semantics.

For this service, the dependency graph had about 30 leaf modules, mostly data parsing utilities and format converters. Pylon-Migrate queued those first. Each module got a PR that included: the syntactic transforms, the unicode/bytes handling changes specific to that module's actual usage patterns, and an updated test file that added coverage for the string handling cases that had been implicit in Python 2 and needed to be explicit in Python 3.

The PRs were small. The median size was about 180 lines changed across the module and its test file. The team reviewed and merged about four per day. At that pace, the leaf modules were done in about eight days.

The unicode/bytes problem in detail

This was the hard part, and it is worth going into some depth because it is where automated migration tools typically fail and where Pylon-Migrate's approach is different from running a script.

In Python 2, str is bytes. unicode is text. They compare equal when one is ASCII-compatible. Implicit coercions happen constantly and silently. A Python 2 codebase that does string operations on web content will have dozens of places where it mixes bytes and text implicitly, and the code works because Python 2 handles the coercion. In Python 3, this throws a TypeError, which is the correct behavior, but it means every one of those implicit coercions is now a migration task.

Pylon-Migrate handles this through a combination of type inference and pattern recognition. For each variable that holds a string value, it traces the value origin: did it come from reading a file (bytes in Python 3 unless explicitly decoded), from an HTTP response body (bytes), from a CSV field (str if read via the csv module), from a function argument (unknown without type annotation), or from a string literal (str in both versions)? Based on origin tracing, it determines the type of each string variable and flags any place where a bytes-origin value is used in a context that expects text, or vice versa.

For about 60% of the flagged locations, the fix was mechanical: add a .decode('utf-8') or .encode('utf-8') at the right point. For about 25%, the fix required understanding the data contract: if a function accepted either bytes or str in Python 2 and was called with both, the Python 3 version needed to explicitly handle both types or impose a type constraint at the call sites. For the remaining 15%, the fix was not obvious from static analysis alone, and Pylon-Migrate flagged those as requiring human review.

Where the agent stopped and humans took over

There were two categories of issues that the migration agent did not attempt to fix on its own.

The first was the codebase's use of the pickle module to serialize intermediate results to disk. The pickle format differs between Python 2 and Python 3 for objects that contain strings, and the team needed to decide whether to migrate existing pickle files, convert the serialization to JSON, or run parallel Python 2 and Python 3 instances during a transition period. That is a data migration decision with production implications, and Pylon-Migrate documented the locations where pickle was used and noted the issue in the PR but did not attempt a solution. The team chose JSON and spent two days writing a migration script for the existing files.

The second was a small set of modules that used exec statements to dynamically generate and run code from configuration files. Those patterns are valid in both Python 2 and Python 3 but the syntax changed, and the dynamic nature meant static analysis could not determine what code was being executed. Pylon-Migrate flagged these modules as requiring manual migration. A senior engineer on the team spent an afternoon on each of them.

The total migration took six weeks. About 70% of the work was done via Pylon-Migrate PRs that the team reviewed and merged. About 20% was human follow-up on the issues the agent flagged. About 10% was the pickle data migration, which was not a code migration task at all. No production incidents occurred during the migration period. The service ran on Python 2 until module 230 was merged and the Python 3 version passed a week of parallel-run validation.

What made this different from running 2to3

The tool the team had tried in 2021 made syntactic changes to all 240 modules simultaneously, without knowledge of the dependency graph or the string type semantics. It produced a codebase that compiled but had latent unicode/bytes bugs throughout. Finding and fixing those bugs would have required the team to essentially re-read every module that touched strings.

Pylon-Migrate's module-by-module approach with dependency ordering meant that the team was reviewing migration changes in units they could understand and test in isolation. When a module's tests passed, the module was done. There was no large untested surface area of "probably fine but we'll find out in production."

We are not saying this approach works for every codebase. Codebases with heavy use of C extensions, complex metaclasses, or framework-level Python 2 compatibility shims may need more custom handling than Pylon-Migrate provides out of the box. This particular codebase was a good fit: mostly pure Python, relatively limited use of dynamic features, and a test suite that, while incomplete, covered the main data paths well enough to validate each module migration before merging.