Python 2 & Legacy Code
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:
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 code | What it meant | Python 3 |
|---|---|---|
print "Hi", name | Print statement | print("Hi", name) |
7 / 2 gives 3 | / on two ints dropped the fraction | 7 / 2 is 3.5; use 7 // 2 for 3 |
raw_input("Name: ") | Read a line as text | input("Name: ") |
input("Age: ") | Read and run the text as code (dangerous) | int(input("Age: ")) |
xrange(10) | A lazy range | range(10) (already lazy) |
d.has_key("a") | Key check | "a" in d |
d.iteritems(), d.itervalues() | Lazy loops over a dict | d.items(), d.values() |
except ValueError, e: | Catch and name the error | except ValueError as e: |
u"text", unicode(x) | Unicode text | "text", str(x) (all str is Unicode) |
10L, long(x) | Big integer type | 10, int(x) (one int type) |
0777 | Octal number | 0o777 |
a <> b | Not equal | a != b |
import urllib2, import ConfigParser | Renamed modules | urllib.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:
stris text: a sequence of Unicode characters, such as"₹1,299 नमस्ते".bytesis 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.
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])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:
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)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:
from __future__ import absolute_import, division, print_function, unicode_literals
import six
if six.PY2:
text_type = unicode
else:
text_type = strfrom __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:
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.
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"))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 style | Modern 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, Optional | list[int], dict[str, int], int | None |
T = TypeVar("T") | def first[T](items: list[T]) -> T: |
setup.py with python setup.py install | pyproject.toml with pip install . or uv (see Packaging with pyproject.toml) |
requirements.txt edited by hand | pyproject.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
strandbytes."Total: " + dataraisesTypeError; decode first. - Leaving out
encoding=when opening files. Old files may be in another encoding, such ascp1252orlatin-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,iteritemsandexcept E, e. - In Python 3,
/always gives a float;//is floor division. stris Unicode text,bytesis raw data; decode at the input, encode at the output, usually with UTF-8.%andstr.format()still work, but write f-strings in new code;%remains normal in logging calls.__future__imports andsixare migration bridges you can remove in pure Python 3 code.- Migrate with tests and small steps;
2to3is gone since Python 3.13, and ruff'sUPrules 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.
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 resultShow solution
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},
}))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?
Dividing two integers in Python 2 dropped the fraction; Python 3 needs // for that.
2. What is the Python 3 equivalent of Python 2's raw_input()?
Python 3 renamed raw_input to input; the old input that ran code was removed.
3. What does "नमस्ते".encode("utf-8") return?
Encoding turns text into bytes; decoding turns bytes back into text.
4. In Python 3.14, what happens when an error reaches the line except ValueError, e:?
Since PEP 758, the comma lists exception types, so the old Python 2 form now fails at runtime.
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
