EthicsComputerCourses
Join training +91 90532 10052

Python 2 & Legacy Code

Python · Lesson 67 of 68 Advanced 8 min read Updated

Not every job starts with fresh code. Many companies, colleges and government offices still run scripts written ten or fifteen years ago, and some of them are in Python 2. Python 2 reached its end of life on 1 January 2020 and gets no more fixes, not even security fixes, but old code does not disappear on a date. You will also meet "old Python 3": code that works today but uses styles from 2012.

This lesson teaches you to recognise both, understand what the old code meant, and move it to modern Python safely. It also covers the one difference that causes the most bugs during a migration: text versus bytes.

Recognising Python 2

The quickest clue is print without brackets. Run that file with Python 3 and you get:

Output
  File "report.py", line 2
    print "Namaste", name
    ^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?

Other signs, with their Python 3 meaning:

Python 2 codeWhat it meantPython 3
print "Hi", namePrint statementprint("Hi", name)
7 / 2 gives 3/ on two ints dropped the fraction7 / 2 is 3.5; use 7 // 2 for 3
raw_input("Name: ")Read a line as textinput("Name: ")
input("Age: ")Read and run the text as code (dangerous)int(input("Age: "))
xrange(10)A lazy rangerange(10) (already lazy)
d.has_key("a")Key check"a" in d
d.iteritems(), d.itervalues()Lazy loops over a dictd.items(), d.values()
except ValueError, e:Catch and name the errorexcept ValueError as e:
u"text", unicode(x)Unicode text"text", str(x) (all str is Unicode)
10L, long(x)Big integer type10, int(x) (one int type)
0777Octal number0o777
a <> bNot equala != b
import urllib2, import ConfigParserRenamed modulesurllib.request, configparser

Python 3 also changed what some functions return: in Python 2, map(), filter(), zip() and d.keys() returned lists, while in Python 3 they return lazy objects. Code like keys = d.keys(); keys.sort() therefore fails and becomes keys = sorted(d).

Text and bytes: the big change

In Python 2, str was a sequence of bytes and Unicode text was a separate type, and the two were mixed silently until a non-English character appeared and the program crashed. Python 3 separates them strictly:

  • str is text: a sequence of Unicode characters, such as "₹1,299 नमस्ते".
  • bytes is raw data: numbers from 0 to 255, such as what is stored in a file or sent over a network.

You convert between them with an encoding, almost always UTF-8: text.encode("utf-8") gives bytes, data.decode("utf-8") gives text back.

PythonRun it yourself
text = "₹1,299 नमस्ते"
data = text.encode("utf-8")

print(type(text).__name__, len(text))
print(type(data).__name__, len(data))
print(data[:5])
print(data.decode("utf-8") == text)

try:
    data.decode("ascii")
except UnicodeDecodeError as error:
    print("UnicodeDecodeError at byte", error.start)
print(data.decode("ascii", errors="replace")[:8])
Output
str 13
bytes 27
b'\xe2\x82\xb91,'
True
UnicodeDecodeError at byte 0
���1,299

The text has 13 characters but needs 27 bytes, because ₹ and the Devanagari letters take three bytes each in UTF-8. Decoding with the wrong encoding either fails or produces replacement characters (�). The rule for migrated code: decode bytes to str as soon as they enter your program (files, sockets, HTTP responses), work with str inside, and encode only when writing out. Opening text files with encoding="utf-8" does this for you.

Old string formatting

Old code formats strings with %, newer code with str.format(). Both still work in Python 3, so you do not have to rewrite them all at once, but f-strings are clearer for new code:

PythonRun it yourself
name, fee = "Rohan", 1299.5

print("%s owes Rs %.2f" % (name, fee))          # oldest style
print("{} owes Rs {:.2f}".format(name, fee))    # str.format(), Python 2.6+
print(f"{name} owes Rs {fee:.2f}")              # f-string, Python 3.6+

print(7 / 2, 7 // 2, -7 // 2)
Output
Rohan owes Rs 1299.50
Rohan owes Rs 1299.50
Rohan owes Rs 1299.50
3.5 3 -4

One place where % style is still correct today is logging: logger.info("Saved %s rows", count) lets the logging module build the message only if the line is actually written. The last line shows the division change; note that // rounds down, so -7 // 2 is -4.

Bridges: __future__ and compatibility libraries

Code that had to run on both Python 2 and 3 during the long transition often starts with lines like:

Python
from __future__ import absolute_import, division, print_function, unicode_literals

import six

if six.PY2:
    text_type = unicode
else:
    text_type = str

from __future__ import ... switched on Python 3 behaviour inside Python 2: print_function made print() a function and division made / return floats. In Python 3 these imports do nothing, so they are safe but can be deleted. The six and future libraries provided names that worked on both versions. When a project drops Python 2, remove these bridges and write plain Python 3.

Modernising an old script step by step

Here is a typical Python 2 script that adds up marks from a file:

Python
import string

def report(path):
    f = open(path)
    totals = {}
    for line in f.readlines():
        name, marks = string.split(line.strip(), ",")
        if not totals.has_key(name):
            totals[name] = 0
        totals[name] = totals[name] + int(marks)
    f.close()
    for name, total in sorted(totals.iteritems()):
        print "%-8s %d (avg %d)" % (name, total, total / 2)

The modern version uses with and an explicit encoding, defaultdict, f-strings and type hints. Notice total // 2: the old / on integers meant floor division, and the migration keeps that meaning on purpose instead of silently changing the output.

PythonRun it yourself
from collections import defaultdict
from pathlib import Path

Path("marks.txt").write_text("Aarav,78\nIsha,91\nAarav,85\nIsha,88\n", encoding="utf-8")


def report(path: Path) -> None:
    totals: defaultdict[str, int] = defaultdict(int)
    with path.open(encoding="utf-8") as f:
        for line in f:
            name, marks = line.strip().split(",")
            totals[name] += int(marks)
    for name, total in sorted(totals.items()):
        print(f"{name:<8} {total} (avg {total // 2})")


report(Path("marks.txt"))
Output
Aarav    163 (avg 81)
Isha     179 (avg 89)

For a real codebase, follow a safe order: first get the old code running with its old version and write tests that capture its current output; then convert one module at a time; run the tests after each step. Python's own 2to3 tool and the lib2to3 module were removed in Python 3.13, so the automatic part of a migration today is done with third-party tools. For code that is already Python 3, ruff's UP (pyupgrade) rules rewrite old styles automatically (see Project Workflow with uv & ruff).

Old Python 3 you will still meet

Older styleModern style
class Shop(object):class Shop:
super(Shop, self).__init__()super().__init__()
os.path.join(BASE, "data", "x.csv")BASE / "data" / "x.csv" with pathlib
from typing import List, Dict, Optionallist[int], dict[str, int], int | None
T = TypeVar("T")def first[T](items: list[T]) -> T:
setup.py with python setup.py installpyproject.toml with pip install . or uv (see Packaging with pyproject.toml)
requirements.txt edited by handpyproject.toml plus a lock file

Common mistakes

  • Converting everything in one huge change. Without tests you cannot tell which change broke what. Migrate in small, tested steps.
  • Changing every / the same way. Decide for each division whether the old integer result was intended (//) or a bug.
  • Mixing str and bytes. "Total: " + data raises TypeError; decode first.
  • Leaving out encoding= when opening files. Old files may be in another encoding, such as cp1252 or latin-1; find out and state it explicitly.
  • Keeping old code on Python 2 "because it works". It gets no security fixes; plan the move.

Key points

  • Python 2 ended on 1 January 2020; recognise it by print "...", raw_input, xrange, has_key, iteritems and except E, e.
  • In Python 3, / always gives a float; // is floor division.
  • str is Unicode text, bytes is raw data; decode at the input, encode at the output, usually with UTF-8.
  • % and str.format() still work, but write f-strings in new code; % remains normal in logging calls.
  • __future__ imports and six are migration bridges you can remove in pure Python 3 code.
  • Migrate with tests and small steps; 2to3 is gone since Python 3.13, and ruff's UP rules modernise Python 3 code.

Exercise

Rewrite this Python 2 function in modern Python 3. Keep its behaviour: it should still return whole rupees per person (rounded down). Add type hints, use an f-string, and replace has_key and iteritems. Test it with a bill of ₹1,000 shared by 3 friends and ₹450 shared by 2.

Python
def split_bills(bills):
    result = {}
    for name, info in bills.iteritems():
        if not info.has_key("people"):
            info["people"] = 1
        share = info["amount"] / info["people"]
        result[name] = share
        print "%s: Rs %d each" % (name, share)
    return result
Show solution
PythonRun it yourself
def split_bills(bills: dict[str, dict[str, int]]) -> dict[str, int]:
    result: dict[str, int] = {}
    for name, info in bills.items():
        people = info.get("people", 1)
        share = info["amount"] // people          # Python 2's int / int rounded down
        result[name] = share
        print(f"{name}: Rs {share} each")
    return result


print(split_bills({
    "dinner": {"amount": 1000, "people": 3},
    "auto": {"amount": 450, "people": 2},
    "tea": {"amount": 60},
}))
Output
dinner: Rs 333 each
auto: Rs 225 each
tea: Rs 60 each
{'dinner': 333, 'auto': 225, 'tea': 60}

info.get("people", 1) replaces the has_key check without changing the caller's dictionary, which the old code did as a side effect. Using // keeps the old whole-rupee result; with / the dinner share would become 333.3333333333333.

Quick Quiz

1. In Python 2, what did 7 / 2 evaluate to?

2. What is the Python 3 equivalent of Python 2's raw_input()?

3. What does "नमस्ते".encode("utf-8") return?

4. In Python 3.14, what happens when an error reaches the line except ValueError, e:?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call