Skip to content

Python 2 vs Python 3: What a 2.4M-Line Migration Looks Like

Python 2 vs Python 3: What a 2.4M-Line Migration Looks Like

Confession: I honestly thought Python 2 died in April 2020, when CPython shipped 2.7.18 and half of tech Twitter posted little obituaries for it. Then last week CCP Games, the studio behind EVE Online, published a dev blog titled “The Move to Python 3 Begins!” and I had to read the headline twice. Begins. In 2026. One of the longest-running multiplayer games on the planet is sitting on 2.4 million lines of Python 2, and they are only now starting the migration.

My first reaction was to laugh. My second was to read the whole post properly, because it turns out to be one of the most honest public write-ups of a legacy migration I’ve seen from any company, let alone a game studio. And the more I read, the less funny it got. Most of us have a project shaped exactly like this. Smaller, obviously. Same shape though.

Why EVE is still on Python 2 in 2026

EVE Online launched in 2003 on Stackless Python, a fork of CPython that adds microthreads. Their last big runtime upgrade was to Stackless 2.7, and that happened in 2010. Sixteen years on one interpreter version. My npm lockfile changes more between breakfast and lunch.

Before you judge them, look at why. Stackless gives you tasklets: extremely cheap cooperative threads, cheap enough that you can have tens of thousands of them on one box. That’s how EVE simulates a single shared universe with thousands of pilots in the same star system. Their entire concurrency model lives in a fork that the rest of the Python world quietly walked away from. Regular CPython 3 doesn’t have tasklets. So “just upgrade” was never one command; it meant re-answering the concurrency question underneath a 23-year-old game.

There’s also the boring economic reason, which I recognize from client work: a codebase that makes money while being old has no natural constituency for change. Nobody gets promoted for swapping the interpreter under a game that already runs. You migrate when the cost of staying finally gets bigger than the cost of moving, and for CCP that line got crossed somewhere around now, when hiring people who want to write Python 2 became its own problem.

Python 2 vs Python 3: the differences that actually bite

If you never lived through this transition, the gap between python 2 vs python 3 sounds cosmetic. Print became a function. Some imports moved. That impression is exactly what makes the migration dangerous, because the nasty differences are the silent ones.

Division is the classic. Same line, same syntax, different answer:

# Python 2
>>> 1 / 2
0

# Python 3
>>> 1 / 2
0.5

Then strings. Python 2 let you smear bytes and text together and usually got away with it. Python 3 makes the boundary explicit:

# Python 2: works, sort of, until a player named
# their ship something non-ASCII
data = "hello" + b"world"

# Python 3: TypeError, on purpose
data = "hello" + b"world".decode("utf-8")  # you must choose

Integer overflow behavior, dict ordering assumptions, the way round() handles .5, comparison of mismatched types: none of these throw a syntax error. They compile, run, and give you a slightly different number than they did yesterday.

The 3,300 lines that block, and the 20,000 that lie

Here’s the pair of numbers from the dev blog that I keep thinking about. Out of 2.4 million lines, only about 3,300 use syntax that Python 3 outright rejects. That’s 0.14 percent. A determined intern with a regex could clear that in a month.

The real number is the other one: roughly 20,000 places where code compiles fine under both versions and behaves differently in Python 3. In EVE, the number coming out of a division might be damage, or ISK, or a ship’s coordinates in space. CCP’s point is that each of those 20,000 lines needs a human decision, not a mechanical fix. Is this division supposed to truncate? Was the old truncation load-bearing? Did some game mechanic accidentally depend on it for two decades, and will players riot if their turret does 0.5 more damage?

I got a tiny taste of this on a PHP project years ago, migrating a client from 5.6 to 8. The syntax errors took a day. The behavior changes took a month, and the worst one was an implicit string-to-number cast inside an invoice calculation that had been silently wrong in a way the business had built a manual workaround for. Fixing the bug broke the workaround. Legacy migration in one sentence, really.

How the staged plan works, and what futurize does

CCP’s first stage is the part worth stealing: they’re making the code Python 3-ready while it still runs on Python 2.7 in production. Not a big-bang rewrite on a branch that drifts for two years. One codebase, compatible with both runtimes, shipped continuously to the live game while the ground shifts under it.

The community tooling for this is futurize, from the python-future project. Stage one applies the fixes that are safe on both versions. Stage two wraps the behavioral differences so your code means the same thing under either interpreter:

# Before futurize
print "docking request accepted"
ratio = shield / capacity

# After futurize
from __future__ import print_function, division
print("docking request accepted")
ratio = shield / capacity  # now true division on both runtimes

That from __future__ import division line is doing quiet heroic work: it forces Python 2 to adopt Python 3’s division semantics, so you flush out the 20,000 liars while your tests still run on the old runtime. You get to find the behavior changes one at a time, in production-adjacent conditions, instead of all at once on launch day.

The dual-runtime window is the same trick I use for data migrations: keep both paths alive, move traffic gradually, never have a moment where rollback means restoring a backup. I wrote about doing this for object storage in my Laravel bucket migration post, and the shape is identical: read-through, dual-write, verify, cut over. CCP is doing it to an interpreter instead of a bucket.

The Stackless question nobody has answered yet

The announcement says nothing about how they’ll replace Stackless itself, which is the part I actually want to read about. There’s a strong hint though. At their fanfest last year, CCP presented a talk called “Scheduling in Carbon: Leaving Stackless Python Behind,” describing how EVE Frontier, their newer game, replaced Stackless with a scheduler built into their Carbon engine. That scheduler is now open source as carbonengine/scheduler. My bet is EVE Online eventually lands on the same thing, with the Python 3 migration as the prerequisite. I’m not sure, and I don’t think they are either yet, which the blog more or less admits. I find that admission more reassuring than a confident roadmap would be.

Simon Willison’s commentary called EVE one of the most interesting case studies in Python at scale, and he’s right. There aren’t many 2.4-million-line Python codebases that have shipped continuously since 2003 and let us watch their migration in public.

What I’d steal from this playbook

Four things, none of them EVE-specific.

Measure your real blocker count before you estimate. CCP found 3,300 hard blockers in 2.4 million lines. If they’d estimated by codebase size, they’d have priced the project at ten times the truth and maybe never started. Run the tool, get the number, then estimate.

Separate mechanical fixes from judgment fixes. The 3,300 and the 20,000 are different projects with different staffing. One is a script; the other is archaeology.

Keep the old runtime in production while you modernize. A migration branch that lives longer than a few weeks is a second codebase, and now you have two problems.

And don’t couple the migration to feature work. The moment “upgrade Python” and “rework the tackle mechanics” share a milestone, the migration loses the argument every time.

A lot of my own client work is this kind of unglamorous modernization, and the pattern holds at every size: the codebase that scares you has fewer hard blockers than you think, and more quiet liars.

Try this on your repo this week

If you have any Python 2 still breathing in a corner: pip install future, then run futurize --stage1 -j4 yourmodule/ and just read the diff. Don’t apply it. Count the changes. That number is your real migration scope, and I’d bet money it’s smaller than the number in your head.

Already on Python 3? Run pyupgrade --py312-plus across your oldest module instead. Same exercise, same lesson: the gap between the code you have and the code you think you have is measurable, and measuring it takes one afternoon. CCP just did it with 2.4 million lines and 23 years of history watching. Your repo will survive the diff.