Migrate Python 2 to Python 3 across every repo in your org

How to Migrate Python 2 to Python 3 Across Every Repo in Your Org

Mandy Singh

Mandy Singh

June 3, 2026 · // 11 min read

Migrating Python 2 to Python 3 across an organization means running two separate passes on every affected repository: a mechanical fix pass (print statements, xrange, dict iterators, unicode literals) and a string-encoding audit pass (finding every place str and bytes mix silently). The mechanical pass is fast. The encoding pass is where most migrations stall, because the failures don’t show up until the code hits non-ASCII input in production.

Python 2 reached end of life in January 2020. Most engineering orgs still have a handful of services running on it anyway, and rarely the services people use every day. More often it’s the ones underneath those: a batch job that reconciles nightly invoices, an internal reporting service nobody’s touched since 2018, a webhook handler three teams depend on but no one owns. Nobody wants to touch them because touching them means digging through five-year-old code for a fix that has to work on the first try.

Those services can’t run current versions of any popular library. Every quarter they stay on Python 2, the security-vulnerability backlog behind them gets longer. And the migration keeps losing to whatever’s on this quarter’s roadmap, because it’s real work with no visible payoff until something breaks.

The method below covers both sides of the problem: the actual mechanical fixes, the encoding audit that catches the failures tests miss, and how to run both across dozens of repos without losing a quarter to coordination.

What does a Python 2 to 3 migration actually involve?

The work splits into two layers, and they behave nothing alike.

Layer one is mechanical. print statements become print() calls. xrange becomes range. iteritems, itervalues, and iterkeys become items, values, and keys. unicode and basestring get removed. from __future__ import statements get cleaned up. Integer division needs an audit wherever the code relied on truncation, since / behaves differently now and // is the fix. Tools like 2to3 and six removal handle most of this automatically, and a static analyzer will flag the rest in minutes.

Layer two is the string-encoding boundary, and it’s where migrations actually break. Python 2 coerced str and bytes into each other implicitly, most of the time silently. Python 3 enforces a strict separation. Every file read, every network call, every subprocess invocation that worked under Python 2’s implicit ASCII coercion can fail at runtime under Python 3, and it usually fails months after the migration ships, the first time the code hits input that isn’t plain ASCII. Test suites rarely cover this because the encoding edge cases aren’t the ones anyone thinks to write a test for.

That’s the part that makes this migration expensive: finding every place the encoding fix needs to go.

The mechanical fix list

Run this across the source tree of every repo in scope:

Python 2 patternPython 3 replacement
print "x"print("x")
xrange(n)range(n)
.iteritems() / .itervalues() / .iterkeys().items() / .values() / .keys()
unicode literals, basestringremoved, use str
urllib2urllib.request
ConfigParserconfigparser
cPicklepickle
mock (standalone package)unittest.mock
from __future__ import blocksremoved once on Python 3 only

Then update the build and CI configuration so the target version is actually enforced, not just assumed:

  • setup.py / setup.cfg / pyproject.toml: set python_requires=">=3.11"
  • tox.ini: envlist = py311
  • Dockerfiles: FROM python:3.11-slim
  • .github/workflows/*.yml: python-version: '3.11'

3.11 is a reasonable default target if you want an actively maintained LTS-ish version. Go to 3.12 if nothing in your dependency tree has a C-extension constraint. Drop to 3.10 if one critical dependency lags behind.

The encoding audit: the step teams skip

Before you touch a single line for style, find every place the code crosses an I/O boundary: file opens, network calls, subprocess invocations, database drivers. Each one is a candidate for a str/bytes mismatch that Python 2 papered over.

The fix in each case is the same shape: add an explicit encoding= argument instead of relying on the platform default.

# Python 2, implicit and dangerous under 3
with open("invoices.csv") as f:
    data = f.read()

# Python 3, explicit
with open("invoices.csv", encoding="utf-8") as f:
    data = f.read()

Subprocess calls that pipe binary data are the highest-risk surface in this audit. A subprocess pipe that quietly handled mixed encodings under Python 2 will throw a TypeError under Python 3 the first time it sees a byte sequence that isn’t valid UTF-8. Flag every subprocess and socket call site for manual review instead of trying to auto-patch it. It’s the one place in the whole migration where a wrong guess costs more than the time it saves.

Run the existing test suite under Python 3 before merging anything, and treat any new failure as a real encoding bug, not test flakiness. That’s usually exactly what it is.

Here’s a prompt you can run today

If you’re already running AI-assisted code changes, this is the prompt to hand it, repo by repo:

Migrate every remaining Python 2 service to Python 3.11.

For each repository:
1. Apply the mechanical Python 2→3 fixers across the source tree:
   - `print` statements → `print()` calls
   - `xrange` → `range`, `iteritems`/`itervalues`/`iterkeys` → `items`/`values`/`keys`
   - `unicode` literals and `basestring` removal
   - `from __future__ import` imports cleaned up
   - integer division (`/`) audited; use `//` where the code relied on truncation
2. Replace EOL Python 2-only dependencies with Python 3 equivalents:
   - `urllib2` → `urllib.request`
   - `ConfigParser` → `configparser`
   - `cPickle` → `pickle`
   - `mock` (standalone) → `unittest.mock`
3. Update build config:
   - `setup.py` / `setup.cfg` / `pyproject.toml`: set `python_requires=">=3.11"`
   - `tox.ini`: `envlist = py311`
   - Dockerfiles: `FROM python:3.11-slim`
   - `.github/workflows/*.yml`: `python-version: '3.11'`
4. Convert string handling. `str`/`bytes` mixing is the most common runtime break.
   Audit every file I/O, network I/O, and subprocess call site for explicit encoding.

That prompt works on one repo. The question that actually stalls these migrations is what happens when you have 40 of them, each with its own dependency tree, its own test coverage gaps, and its own owner who has three other things due this sprint.

Why the mechanical fixes aren’t the hard part

A single repo migration like the one above takes an afternoon. The math changes once you have more than a handful of Python 2 services scattered across different teams.

Each repo needs its own dependency audit, because the Python 2-only packages it depends on aren’t the same ones the repo next to it depends on. Each one needs a human to review the subprocess and encoding call sites, because that step doesn’t fully automate. Each one needs a test pass, a PR, a reviewer, and someone to actually merge it. Multiply that by 40 repos owned by 12 different teams, and the migration that took an afternoon technically becomes a project that nobody has time to run end to end.

That’s why so many orgs still have Python 2 running somewhere years after the language’s own end-of-life date. The technical fix was never the blocker. Tracking who’s done, who’s blocked, and who hasn’t started across every affected team is the part that makes the work stall.

A platform engineer we work with put it this way: “Without Tidra, those PRs would likely never get done. Now it’s easy.”

Running the migration across every repo, not just one

The change is defined. The mechanical fixers and the encoding pattern above cover the actual code. The missing piece for most teams isn’t knowing what to change. What’s missing is executing that change consistently across every repository that needs it, and tracking it to completion without a spreadsheet.

Tidra is an AI coding agent for both implementation and coordination of code changes across your organization. For a migration like this one, that means:

  1. Target every Python 2 repo with one filter. Set up the initiative once, scope it to the repos that still run Python 2, and skip the manual repo-by-repo hunt.
  2. Run the same prompt across all of them. The mechanical fixes and the encoding audit above become the initiative’s instructions. Tidra generates the change for each repo individually, respecting that repo’s own dependency tree and test setup.
  3. Review the plan before any code moves. Nothing gets touched until you’ve seen what Tidra proposes to do in each repo and confirmed it. If a repo needs a different Python target version or a different dependency mapping, you adjust the plan there.
  4. Bulk-create PRs, not one-off ones. Each repo gets its own reviewable pull request. Your engineers review and merge, same as any other PR.
  5. Track completion from one dashboard, instead of a spreadsheet that goes stale the day after you build it.

“This would have taken us 6 weeks to coordinate manually. We did it in 3 days,” is how one platform team described the difference between running a migration like this by hand and running it through a single initiative.

Tidra doesn’t replace the judgment calls in the encoding audit. The subprocess pipes and binary I/O call sites still need a human to look at them. What changes is that the 40 repos stop being 40 separate side projects competing for attention, and become one initiative with a single owner and a status you can actually see.

Before and after: what the diff looks like

--- pyproject.toml
+++ pyproject.toml
@@ -1,15 +1,15 @@
 [project]
 name = "reporting-service"
 version = "2.4.0"
-requires-python = ">=2.7"
+requires-python = ">=3.11"
 dependencies = [
-    "six>=1.12",
-    "futures>=3.3",
-    "mock>=3.0",
-    "configparser>=4.0",
+    "requests>=2.31",
 ]

 [tool.tox]
-envlist = ["py27"]
+envlist = ["py311"]
+
+[tool.ruff]
+target-version = "py311"

That’s what a single repo’s PR looks like. At 40 repos, it’s 40 PRs that look like this, each generated against that repo’s actual dependency file, each opened for the team that owns it to review.

Python 2 to 3 migration checklist

Use this as a per-repo gate before you call a migration done:

  • Mechanical fixers applied and code compiles clean under 3.11
  • Every Python 2-only dependency replaced or confirmed unnecessary
  • Every file, network, and subprocess I/O call site has an explicit encoding= argument, or has been flagged for manual review
  • setup.py / pyproject.toml / tox.ini / Dockerfile / CI workflow all declare the same target version
  • Full test suite passes under Python 3 with parity to the Python 2 pass rate
  • Subprocess and socket calls that touch binary data have been reviewed by a person, not just the automated pass

What usually breaks after a Python 2 to 3 migration ships

Almost always the same thing: a code path that only runs on non-ASCII input. A customer name with an accent, a file upload from a system using a different encoding, a log line with a byte sequence nobody tested against. The migration passes CI, ships, and runs clean for weeks until it hits that one input. That’s the failure mode the encoding audit exists to catch before merge instead of after deploy.

FAQ

Is Python 2 still supported in 2026? No. Python 2 reached end of life in January 2020. No security patches, no bug fixes, and most current third-party libraries have dropped support for it entirely.

What’s the hardest part of migrating Python 2 to Python 3? The str/bytes boundary is harder than any syntax change. Python 2 coerced strings and bytes into each other implicitly; Python 3 enforces a strict separation, and code that worked for years under Python 2’s implicit ASCII handling can fail at runtime the first time it sees non-ASCII input.

Can Python 2 to 3 migration be automated? The mechanical fixes (print statements, xrange, dict iterator methods, dependency swaps, build config updates) automate well. The encoding audit needs a human review pass on subprocess and binary I/O call sites, since a wrong automated guess there is more expensive than the time it saves.

How do you migrate Python 2 to 3 across many repositories at once? Define the mechanical fix pattern and encoding audit once, then run it as a single initiative scoped to every repo still on Python 2. Each repo gets its own generated change and its own PR, reviewed by the team that owns it, tracked from one place instead of a spreadsheet per team.


Ready to run this across your repos? Connect your Git provider and Tidra opens pull requests in every repo that needs them: tidra.ai/get-started?initiative=python-2-to-3