Appendix A — Moving old Python code to the modern style
A step-by-step guide for moving 2017-era Python code (% strings, has_key, type() comparison, etc.) to modern Python. So readers of the old 21-part Python basics tutorial can continue naturally into this book.
This appendix is a migration guide for people who read this site’s old 21-part Python basics tutorial (2017), or who learned Python in the 2.x ~ 3.5 era and then stopped there.
If Chapters 1–33 are “starting modern from the beginning,” this appendix is a guide for taking old code in hand and moving it to modern one step at a time. Knowing both lets you keep the old tutorial’s material and refresh it instead of throwing it away.
Old / new 1:1 mapping — a one-page summary #
The table below is the compressed form of this appendix. Each row is revisited with paired code further down.
| Area | Old code (~2017) | New code (modern) |
|---|---|---|
| Output | print "hello" (py2) | print("hello") |
| String formatting | "hi %s" % name / .format() | f"hi {name}" |
| Integer division | 5 / 2 → 2 (py2) | 5 / 2 → 2.5 / integer is 5 // 2 |
| dict key check | d.has_key("k") | "k" in d |
| Exception catch | except Exception, e: | except Exception as e: |
| Iteration range | xrange(10) | range(10) (already lazy) |
| Unicode | u"한글", unicode type | just "한글", every str is unicode |
| Type check | type(x) == int | isinstance(x, int) |
| Class boilerplate | def __init__(self, a, b): self.a=a; self.b=b | @dataclass |
| Type info | :type x: int in docstring | x: int annotation |
| Collection types | from typing import List + List[int] | list[int] (3.9+) |
| Optional type | Optional[int] | `int |
| Paths | os.path.join(a, b) | Path(a) / b |
| External commands | os.system("ls") / Popen | subprocess.run(["ls"]) |
| Async | callbacks / generator coroutines | async def + await |
| Toolchain | pip install + venv + pyenv | uv add + uv run + uv python install |
Step-by-step conversions #
1. print became a function
#
print "hello"
print "x =", xprint("hello")
print("x =", x)Run old code through a Python 3 interpreter and you get a SyntaxError immediately. It’s a mechanical conversion, and tools catch it in one shot (see §“Automation” below).
2. String formatting — % / .format() → f-string
#
Three generations coexist. New code is f-string, no exceptions.
"hi %s, you are %d" % (name, age)"hi {}, you are {}".format(name, age)
"hi {name}, you are {age}".format(name=name, age=age)f"hi {name}, you are {age}"
f"{name = }, {age = }" # for debuggingFor detailed usage, see Chapter 2 Variables, basic types, and type hints.
3. Integer division #
5 / 2 # 2 (int / int returns int)
5.0 / 2 # 2.55 / 2 # 2.5 (always float)
5 // 2 # 2 (explicit integer division with //)If old code wrote / and expected an int, it breaks in Python 3. If you meant integer division, write // explicitly.
4. dict methods — has_key is gone
#
if d.has_key("user"):
...if "user" in d:
...in is shorter and works consistently across non-dict collections.
5. Exception catch — , e → as e
#
try:
do_work()
except Exception, e:
print "failed:", etry:
do_work()
except Exception as e:
print(f"failed: {e}")The , syntax is a SyntaxError in Python 3. Standardize on as.
Additionally, from Python 3.11 onwards there’s ExceptionGroup + except*. It’s a new tool for handling multiple simultaneously raised exceptions, covered in Chapter 6.
6. xrange is gone — range is already lazy
#
for i in xrange(10):
...for i in range(10):
...Python 3’s range absorbed the behavior of old xrange. It doesn’t materialize a list up front — it yields values lazily.
7. Unicode — every str is unicode
#
s = u"한글" # needed the u prefix
isinstance(s, unicode)s = "한글" # plain str is already unicode
isinstance(s, str)The unicode type doesn’t exist. str = unicode, bytes = byte sequence. The two don’t auto-convert; use .encode() / .decode() explicitly.
8. Type checks — use isinstance() instead of type() comparison
#
if type(x) == int:
...if isinstance(x, int):
...isinstance understands inheritance. type() comparison is true only for the exact same type, so it doesn’t handle subclasses. And in modern Python, the trend is to move away from runtime type checks altogether and rely on type hints + static verification (see Chapter 2 type checkers).
9. Class boilerplate — dozens of __init__ lines compressed into one @dataclass
#
class User(object):
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
def __repr__(self):
return "User(id=%r, name=%r, email=%r)" % (self.id, self.name, self.email)
def __eq__(self, other):
if not isinstance(other, User):
return False
return (self.id, self.name, self.email) == (other.id, other.name, other.email)from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str__init__, __repr__, and __eq__ are generated automatically. Extra options (frozen, slots, kw_only) are all available in the same one-liner. Covered in depth in Chapter 8 dataclass and __slots__.
The (object) in class User(object): is another old artifact. In Python 3 every class inherits from object automatically, so it’s omitted.
10. Type info — from docstring to annotation #
def add(a, b):
"""
:param a: first integer
:type a: int
:param b: second integer
:type b: int
:rtype: int
"""
return a + bdef add(a: int, b: int) -> int:
"""Add the two and return the result."""
return a + bType info moves up to the signature. The IDE, the type checker, and the documentation generator all read the same source. The docstring is for why and how to use.
11. Collection types — typing import → built-in generics
#
from typing import List, Dict, Tuple, Set
names: List[str] = []
ages: Dict[str, int] = {}
point: Tuple[float, float] = (0.0, 0.0)
unique: Set[str] = set()names: list[str] = []
ages: dict[str, int] = {}
point: tuple[float, float] = (0.0, 0.0)
unique: set[str] = set()from typing import List itself is going away. You write [] directly on built-in types.
12. Optional types — Optional → T | None
#
from typing import Optional, Union
def find(id: int) -> Optional[str]:
...
def parse(s: str) -> Union[int, float, None]:
...def find(id: int) -> str | None:
...
def parse(s: str) -> int | float | None:
...Shorter, with no extra import.
13. Paths — os.path → pathlib.Path
#
import os
config_path = os.path.join(os.path.dirname(__file__), "config", "app.toml")
with open(config_path, "r") as f:
data = f.read()from pathlib import Path
config_path = Path(__file__).parent / "config" / "app.toml"
data = config_path.read_text()Path objects compose with the / operator and carry methods like .read_text() / .write_text() / .exists() / .glob(). You memorize fewer os.path functions.
14. External commands — os.system / Popen → subprocess.run
#
import os
os.system("ls -la /tmp") # no result captured, security riskfrom subprocess import Popen, PIPE
proc = Popen(["ls", "-la", "/tmp"], stdout=PIPE, stderr=PIPE)
out, err = proc.communicate()import subprocess
result = subprocess.run(
["ls", "-la", "/tmp"],
capture_output=True,
text=True,
check=True,
)
print(result.stdout)Passing args as a list is the default — less shell-injection risk. check=True raises on non-zero exit, capture_output=True captures stdout / stderr, text=True returns str instead of bytes.
15. Async — callbacks / generator coroutines → async def + await
#
import asyncio
@asyncio.coroutine
def fetch():
response = yield from get_url("https://example.com")
return responseasync def fetch():
response = await get_url("https://example.com")
return responseThe async def / await keywords became standard, and the @asyncio.coroutine decorator is deprecated. Covered in Chapter 14 async intro and Chapter 18 async in depth.
Automation — you don’t have to do all of this by hand #
Most of the conversions above are mechanical. Tools catch them in one pass.
pyupgrade #
uv tool install pyupgrade
pyupgrade --py312-plus path/to/file.pyWhat it handles (partial list):
%format → f-string (simple cases)- Drop
from typing import List+ replaceList[int]withlist[int] Optional[X]→X | NoneDict[K, V]→dict[K, V]super(ClassName, self)→super()- Old
printstatements → function form .format()cleanup inside f-strings
The --py312-plus argument means “assume my code only runs on Python 3.12+ and rewrite everything to the newest syntax available there.” For this book’s baseline, that’s --py314-plus.
ruff’s UP rules — pyupgrade integrated into ruff #
[tool.ruff.lint]
select = ["UP"] # enable pyupgrade rulesuv run ruff check . --select UP --fixruff absorbed most of pyupgrade’s rules and you can run them alongside other lint rules in one pass. Wire it into pre-commit and old patterns are blocked from coming back — Chapter 30 Type checker setup and CI integration covers the formal setup.
What can’t be automated #
- Converting a class to
@dataclass(intent judgment needed) - Converting
os.pathtopathlib(depends on where and how it’s used; human judgment) - Callbacks / generator coroutines →
async def(structural change) - Docstring type notation → annotations (tools exist, accuracy is limited)
The four above are safer to migrate by hand.
Old tutorial → this book — a recommended path #
If you came from the old 21-part Python basics tutorial, I recommend this order.
- This Appendix A first — see at a glance how patterns from the old tutorial era look different in the new book.
- Chapter 1 Getting started and uv setup — the toolchain changed completely, so reinstall the environment from scratch.
- Chapter 2 Variables, basic types, and type hints — internalize the biggest shift, “type hints first.”
- Chapters 3–7 — revisit topics you already know from the old tutorial (control flow, collections, functions, exceptions, modules) in modern phrasing. Familiarity makes them quick reads.
- Part 2 onward — enter areas the old tutorial didn’t cover (dataclass, Protocol, async, FastAPI, operations).
The old tutorial remains permanently on the site. Even after you’ve moved on to this book, occasionally crack open an old page and compare “this phrasing got rewritten this way in the new book.”
Exercises #
Move an entire chunk of old code to modern style at once.
import os
class User(object):
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
def __repr__(self):
return "User(id=%r, name=%r)" % (self.id, self.name)
def load_users(path):
"""
:param path: path to users.txt
:type path: str
:rtype: List[User]
"""
full = os.path.join(os.path.dirname(__file__), path)
users = []
f = open(full, "r")
try:
for line in f:
parts = line.strip().split(",")
if len(parts) != 3:
continue
id_str, name, email = parts
users.append(User(int(id_str), name, email))
finally:
f.close()
return users- Convert the code above to modern style using this appendix’s mapping. Hints:
@dataclass, type annotations,pathlib.Path,with open(...) as f:,list[User], f-string. - Run
pyrighton the converted code and confirm it’s error-free. - Run
pyupgrade --py314-plus old_user.pyand compare which parts the tool handled automatically vs. which still required a human hand.
In one line: Most conversions of old Python code are mechanical, so
pyupgradeandruff --select UPcatch them. The parts that need a human hand are@dataclass,pathlib,async/await, and docstring types. The recommended path from the old tutorial to this book is Appendix A → Chapter 1 → Chapter 2 → quick read through Chapters 3–7 → Part 2 onward.