Python migration guide

How to migrate Python 2 to Python 3

Python 2.7 reached end of life on 1 January 2020. Since that day, the core team has shipped no security patches, no bug fixes, and no compatibility updates. Every unpatched CVE in the interpreter or the standard library that has landed since is permanent for anyone still on 2.7. If your build pipeline still installs Python 2, you are running production code on an interpreter that will never be fixed again — an audit finding, a compliance gap, and a widening pool of libraries that have dropped support out from under you.

The good news: the migration is well-trodden and largely mechanical. Most of the churn is in a handful of breaking changes with known, tool-assisted fixes. This guide walks through what actually breaks, how to de-risk your dependencies, and a staged process that keeps your test suite green the whole way. If you want a fast read on how exposed you are before committing, run your runtimes through the EOL checker first.

What actually breaks

The Python 3 changes that bite in practice are concentrated. Here is the working set, with before/after code and the mechanical fix for each.

print statement becomes a function

The single most common change. print is no longer a statement; it is a function call. The soft-landing move is to import the future behaviour so the code runs identically under both interpreters.

print "processing", filename           # Py2 statement
print >>sys.stderr, "failed"           # Py2 redirect
from __future__ import print_function  # top of file
print("processing", filename)
print("failed", file=sys.stderr)

Integer division: / versus //

In Python 2, / between two integers truncates. In Python 3, / is always true division and returns a float; // is the floor-division operator. This is a silent behaviour change — code keeps running but produces different numbers — so it is the most dangerous item on this list.

midpoint = (low + high) / 2      # Py2: int; Py3: float -> breaks indexing
from __future__ import division  # makes / true-division under Py2 too
midpoint = (low + high) // 2     # explicit floor division where you meant it

Do not let the tools "fix" division for you blindly. Automated modernizers cannot know whether a given / meant "floor" or "true" division. Add from __future__ import division to every module early, then read each division site and decide. Off-by-one bugs and subtly wrong money/geometry math are the classic way a Python 3 migration ships a regression.

The str/bytes/unicode model

This is the change that turns a weekend into a fortnight for text-heavy or network code. In Python 2, str is bytes and unicode is a separate type, and the two coerce implicitly (often wrongly). In Python 3, str is Unicode text and bytes is a distinct type with no implicit coercion between them. You must decide, at every I/O boundary, whether you are holding text or bytes and encode/decode explicitly.

data = urllib2.urlopen(url).read()   # Py2: str (bytes-ish), used as text
header = "X-Id: " + user_id           # mixes str and unicode silently
raw = urlopen(url).read()             # Py3: bytes
data = raw.decode("utf-8")            # decode at the boundary -> str (text)
header = "X-Id: " + user_id           # both are str; no coercion surprises

Rule of thumb: decode bytes to str as soon as they enter your program, work in str internally, and encode back to bytes only when writing to a socket, file opened in binary mode, or subprocess. Open text files with an explicit encoding=.

Dictionary views: iteritems, iterkeys, keys

Python 3 removed dict.iteritems(), iterkeys(), and itervalues(). The plain keys(), values(), and items() now return lightweight view objects rather than lists. Code that indexed or mutated the returned list breaks.

for k, v in counts.iteritems():   # gone in Py3
first = counts.keys()[0]          # views aren't indexable
for k, v in counts.items():       # or six.iteritems(counts) for both
first = list(counts.keys())[0]    # materialize when you need a list

xrange, range, and lazy iterators

xrange is gone; range in Python 3 is the lazy iterator that xrange used to be. Similarly map, filter, and zip now return iterators, not lists — wrap them in list() if you index or reuse the result.

for i in xrange(n):        # NameError in Py3
squares = map(f, items)   # Py2: list; Py3: iterator (consumed once)
for i in range(n):
squares = list(map(f, items))

dict.has_key()

Removed entirely. Use the in operator, which also reads better and worked in Python 2.

if config.has_key("timeout"):
if "timeout" in config:

Relative imports

Python 3 makes implicit relative imports an error. Inside a package, a bare import sibling no longer silently resolves to a module in the same package — you must be explicit.

import utils              # implicit relative; ImportError in Py3
from . import utils       # explicit relative
from mypkg import utils   # or absolute

Exception syntax and chaining

The comma form for binding an exception is gone; use as. Raising with the three-argument form also changed.

try:
    do_work()
except IOError, e:        # SyntaxError in Py3
    log(e.message)        # .message removed
try:
    do_work()
except IOError as e:
    log(str(e))

Comparison of unlike types

Python 2 would happily order a str against an int (using an arbitrary, type-name-based ordering). Python 3 raises TypeError. This surfaces most often when sorting heterogeneous lists or using None as a sentinel in a sort key.

rows.sort()               # Py2 tolerates mixed types; Py3 raises TypeError
rows.sort(key=lambda r: (r is not None, r))  # sort None-safely, explicitly

input() versus raw_input()

Python 2's input() evaluated its input as code (a genuine security hole); raw_input() returned the string. Python 3 renamed raw_input to input and removed the eval-ing version.

name = raw_input("Name: ")   # gone in Py3
name = input("Name: ")       # returns a str, no eval

Feature reference table

FeaturePython 2Python 3Action
printprint x (statement)print(x) (function)from __future__ import print_function; rewrite calls
Division3/2 == 13/2 == 1.5, 3//2 == 1from __future__ import division; review each site, use // where floor intended
Text/bytesstr=bytes, separate unicodestr=text, separate bytesDecode at input, encode at output; add encoding= to open()
Dict iterationiteritems(), list keys()removed; view objectsUse items()/keys(); list() when a list is needed; six.iteritems() for both
Rangesxrange, list map/ziprange lazy; iterator map/zipxrangerange; wrap in list() where indexed/reused
Membershipd.has_key(k)removedUse k in d
Importsimplicit relative allowederrorfrom . import x or absolute imports
Exceptionsexcept E, e:, e.messageexcept E as e:Use as; use str(e) / e.args
Mixed comparisonarbitrary orderingTypeErrorProvide explicit, type-homogeneous sort keys
Console inputraw_input(), eval-ing input()input() returns strraw_inputinput

The real risk: your dependencies

The language changes are finite and tool-assisted. Your third-party dependencies are where migrations actually stall. A library that was abandoned before 2020 may have no Python 3 release at all, and a transitive pin can hold your whole tree hostage.

Inventory first. Freeze your current environment and run it through caniusepython3, which cross-references your requirements against PyPI classifiers and known ports:

pip freeze > requirements.txt
pip install caniusepython3
caniusepython3 -r requirements.txt

For each blocker the report flags, you have three options: upgrade to a Python 3-capable release, swap in a maintained replacement (for example requests in place of raw urllib2, or python-dateutil/arrow for date parsing), or vendor and port the code yourself if it is small and unmaintained. Resolve every blocker on paper before you touch application code — discovering a dead dependency halfway through a rewrite is how migrations get abandoned.

How to migrate: a staged process

The safest path never has a "big bang" cutover. You make the code run under both interpreters, keep the tests green throughout, and flip the default only once Python 3 is proven in CI. Here is the sequence.

  1. Freeze dependencies and get the suite green on 2.7. You cannot detect regressions without a baseline. Pin every dependency (pip freeze), make sure the test suite passes on the interpreter you are leaving, and measure coverage. Migrating code that has no tests is the single biggest source of silent breakage — write characterization tests for critical paths first if coverage is thin.
  2. Run caniusepython3 and clear every dependency blocker. Upgrade, replace, or vendor each Python 2-only package until the report is clean. Do this before changing your own code.
  3. Auto-modernize with futurize (or python-modernize). These apply the mechanical fixes above and route them through the six and __future__ compatibility shims, so the result runs on both 2 and 3. Run stage 1 (safe changes) first, review the diff, run tests, then stage 2. Never merge a modernizer diff unread — pay special attention to every division and every text/bytes boundary it touched.
    pip install future
    futurize --stage1 -w mypackage/   # safe, no behaviour change
    # run tests, commit
    futurize --stage2 -w mypackage/   # adds six/__future__ shims for 2+3
  4. Make the code run cleanly under both interpreters. Fix what the tools could not: text/bytes decisions, ambiguous division, mixed-type sorts. The goal of this stage is a single codebase whose tests pass on both 2.7 and 3.x. Add a Python 3 job to CI alongside the existing 2.7 job.
  5. Flip CI and deployment to Python 3. Make Python 3 the interpreter of record: promote the Python 3 CI job to required, deploy on 3.x, and keep the 2.7 job as a safety net for as long as you need a rollback path.
  6. Drop six and __future__ once 2.7 is retired. When you no longer run on Python 2, the compatibility shims are dead weight. Remove six usages, the from __future__ import ... lines, and any if PY2: branches. Tools like pyupgrade automate most of this cleanup.
  7. Move to a supported 3.x — target 3.12. Do not stop at whatever 3.x you first got green. Older 3.x lines fall out of security support too. Land on a current, supported release (3.12 at time of writing) and adopt pyupgrade --py312-plus to take advantage of f-strings, dict ordering guarantees, and modern typing syntax.

Keep the tests green at every single step. The discipline that makes this migration boring instead of terrifying is refusing to advance a stage while any test is red. A dual-running codebase with a passing suite on both interpreters is a position you can pause at indefinitely — which matters, because real migrations get interrupted.

For migrating other stalled runtimes and frameworks on the same staged model, see the sibling migration guides, and confirm your current end-of-life exposure with the EOL checker.

Frequently asked questions

Is Python 2 really end of life?

Yes, unambiguously. Python 2.7 — the last 2.x release — reached end of life on 1 January 2020. The core development team ships no further releases: no security fixes, no bug fixes, no compatibility patches. The final release, 2.7.18, came in April 2020 to tie off loose ends, and that is the end. Any security vulnerability discovered in the Python 2 interpreter or standard library since then is unpatched and will stay that way. Running Python 2 in production today is an accepted, permanent, and growing risk.

2to3 or futurize — which should I use?

Prefer futurize (or python-modernize) for almost every real project. The original 2to3 tool performs a one-way conversion: it turns Python 2 code into Python 3 code that no longer runs on Python 2. That forces a big-bang cutover with no safety net. futurize instead produces code that runs on both interpreters by leaning on the six and __future__ shims, which is exactly what lets you keep tests green and deploy incrementally. Reserve one-way 2to3 for throwaway scripts where dual support has no value.

Should I target the latest 3.x?

Target a currently supported 3.x, which means 3.12 at the time of writing — not the newest possible and not the oldest that works. Migrating onto an already-old 3.x line (say 3.7, itself past end of life) just schedules a second migration. Land on a release with years of security support ahead of it, then use pyupgrade to adopt its modern syntax. Do check that your dependencies support your chosen version before committing.

How long does a Python 2 to 3 migration take?

Honestly, it depends almost entirely on two things: your test coverage and your dependency health. A well-tested application with all dependencies already Python 3-capable can be a matter of days — the mechanical fixes are fast and tool-assisted. A large codebase with thin tests, heavy string/bytes handling, and one or more abandoned dependencies can run to weeks or months, most of it spent on dependency replacement and text/bytes correctness rather than on the language changes themselves. The staged, dual-running approach lets you spread that work across normal release cycles instead of freezing feature work for a single risky cutover.

Don't want to do this by hand?

Mira runs this exact migration for you — detect, modernize, add tests, run a security pass — on your own servers, in hours, not months.

Scan your repo free → Check your version's EOL →