Topic 10.9
Writing Modern Python: An Idiom Upgrade
In one line
Python has changed a lot since the code in old tutorials was written. This topic puts the old way and the modern way side by side, so you can read old code, write current code, and know what each recent version added.
Think of it like this
A kitchen that gets new tools over the years. A cook who still grates cheese with a fork gets the job done, but a cook who knows the grater is faster and safer. Old Python code works, and new Python code does the same job with a better tool. You need to recognise both.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Idiom
- The usual, preferred way to write a common thing in a language.
- Deprecated
- Still works, but scheduled for removal; new code should not use it.
- Backport
- A package that brings a newer language feature to older Python versions.
- `requires-python`
- The setting in
pyproject.tomlsaying which Python versions your project supports. - Free-threaded build
- A Python build without the global interpreter lock, so threads can run Python code in parallel.
- t-string
- A template string (3.14) written
t"..."that produces an object holding the pieces instead of finished text. - JIT
- Just-in-time compiler: a part that turns frequently used code into machine code while the program runs.
- Linter
- A tool that reads code and flags or fixes style and likely mistakes.
Step by step
01Strings and paths
The first three lines print the same text, from oldest style to newest. The third is the one to write today. The fourth shows the name and the value together. For paths, Path("notes") / "a.txt" works on Windows, macOS and Linux with the right separator, and has .read_text(), .write_text() and .exists() built in.
name, version = "hectal", 3
print("%s v%d" % (name, version)) # oldest
print("{} v{}".format(name, version)) # older
print(f"{name} v{version}") # current
print(f"{version=}") # shows name and value: version=302Dictionaries, types and prefixes
These are small gains that add up. defaults | overrides makes a new dict where the right side wins. removeprefix only removes the prefix if it is there, with no off-by-one slicing. Types lose their imports: list[int], dict[str, int], int | None.
defaults = {"theme": "dark", "size": 12}
user = {"size": 14}
print(defaults | user)
print("v1.2.3".removeprefix("v"), "file.py".removesuffix(".py"))03Safer loops
zip(a, b, strict=True) raises ValueError if the lengths differ, instead of silently ignoring the longer one. This catches a very common data bug (a column that lost a row). enumerate(items, start=1) numbers from 1. The walrus := assigns inside an expression: handy to call something once and test the result.
04match, StrEnum and slots dataclasses
Replace an if/elif ladder that tests shape with match (Topic 10.8). Replace class Color(str, Enum) with StrEnum, whose members are strings: f"{Color.BLUE}" gives blue. @dataclass(slots=True, kw_only=True) saves memory (Topic 8.12) and forces named arguments, which makes constructor calls readable.
05Errors and configuration
ExceptionGroup carries several errors together; except* handles each kind (Topic 7.3). tomllib.loads parses TOML text with nothing to install; it only reads, so to write TOML use a third-party library.
063.13 and 3.14 in practice
copy.replace(obj, field=value) makes a modified copy of a frozen dataclass, named tuple or similar (3.13). warnings.deprecated marks your own functions as deprecated for both callers and type checkers (3.13). Python 3.14 template strings capture the *parts* of an interpolated string so a library can escape or validate them. That is useful for SQL (the language of databases), HTML and shell commands, and for building prompts safely.
The risk they address is injection: text typed by a user gets pasted into a command and changes what the command does. See the non-runnable example below.
07Let a tool do it
Ruff can find and fix most old idioms. Turn on its UP (pyupgrade) rules with your target version. The change is mechanical, so review the diff, run the tests and the type checker, and commit.
[project]
requires-python = ">=3.12"
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "UP"]Try it yourself
- 1
Modernise an old function
Take this old code and rewrite it with modern idioms:
from typing import List, Optional,def total(items: List[int], default: Optional[int] = None) -> int:that returnsdefaultfor an empty list. Uselist[int],int | Noneand a conditional expression. - 2
Catch a silent bug
In the first example, change
zip(..., strict=True)to plainzipand note that the error vanishes: the extra item is silently dropped. That is the bugstrict=Trueexists to expose. - 3
Check your project's age
Look at a project you have, find the lowest Python version it supports (
requires-python), and list three idioms from this topic that it could already use.
Code & diagrams
Several upgrades in one run: f-string =, dict |, removeprefix, walrus, zip(strict=True), StrEnum, tomllib, pathlib, slots dataclass, except*. It writes a small folder to the run's scratch directory.
Expected output
hectal v3 | hectal v3 | hectal v3 | version=3
merged: {'theme': 'dark', 'size': 14}
trimmed: 1.2.3 file
walrus: long list of 3
zip strict: zip() argument 2 is shorter than argument 1
StrEnum: red True blue
toml: {'server': {'port': 8080, 'hosts': ['a', 'b']}}
pathlib: ['a.txt'] hello
User(name='Asha', age=0)
except*: (ValueError('bad value'),)
except* key: (KeyError('k'),)Each pair computes the same thing the old way and the new way. The match version of old_style is the same logic with a clearer shape.
Expected output
merge dicts same: True
format text same: True
strip prefix same: True
isinstance tuple same: True
enumerate from 1 same: True
None none none
-5 negative negative
0 zero zero
7 positive positive
['alpha', 'gamma-delta'] last size seen: 11copy.replace makes a changed copy of a frozen object. @deprecated marks your own function and raises a DeprecationWarning when it is called; here the warning is captured and printed.
Expected output
Config(model='small', temperature=0.0)
Config(model='small', temperature=0.7)
old
DeprecationWarning - use new_api() insteadNot runnable in the browser (Python 3.13). A t-string does not build a finished string: it keeps the literal parts and the interpolated values apart, so code can treat the values safely.
# Python 3.14 or later
from string.templatelib import Template, Interpolation
user_input = "'; DROP TABLE users; --"
query = t"SELECT * FROM users WHERE name = {user_input}"
print(type(query).__name__) # Template
print(query.strings) # ('SELECT * FROM users WHERE name = ', '')
print(query.interpolations[0].value) # the raw value, kept separate
# a library can now bind the value as a parameter instead of pasting it into text
# except with several types and no brackets
try:
int("x")
except ValueError, TypeError:
print("caught either")Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a new feature on an older Python
Call copy.replace, added in 3.13, on Python 3.12.
import copy
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
model: str
print(copy.replace(Config("small"), model="large"))Myth vs fact
Myth
Old-style code is wrong and must be rewritten.
Fact
Old idioms still work. Modernise when you touch the code, or in a planned, tested pass with a tool. Do not rewrite for fashion.
Myth
Newer Python is only about new syntax.
Fact
Every release also brings speed and better error messages. Upgrading Python is often the cheapest speed-up.
Myth
Free-threaded Python makes all code faster.
Fact
It lets threads run Python code at the same time. Programs that were not limited by the global lock gain nothing, and single-threaded code can be a little slower.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Each Python version is supported for about five years and released every October. Check the supported versions when starting a project, and avoid being on a version that has reached end of life: it receives no security fixes.
- ▸
Use
typing_extensionsfor newer typing features on older Python, andfrom __future__ import annotationsto useX | Yin annotations on 3.7-3.9 (it does not help at run time, e.g. inisinstance). - ▸
Template strings (3.14) are the safe replacement for building SQL, HTML or shell commands by f-string, and prompts that must keep user text separate from instructions (Topic 16.7 and the prompt injection discussion in the AI phase). They protect only when a library actually processes the parts; a t-string by itself is not an escape.
- ▸
Free-threaded builds, the experimental JIT and lazy imports change performance rather than syntax. Test your dependencies on them before relying on them; some C extensions need updates.
- ▸
Upgrade traps worth knowing.
a | bis a shallow merge: a nested dict under a repeated key is replaced, not merged, anda |= bmutatesain place (and, unlike|, also accepts an iterable of pairs).tomllib.loadneeds a file opened in binary mode (open(path, "rb"));loadstakes text. AStrEnummember prints as its plain value (str()and f-strings givered), whereas with the olderclass C(str, Enum)both giveC.REDon Python 3.12 and later (before 3.12 the f-string gaveredbutstr()gaveC.RED), so switching can change output.zip(strict=True)checks only when an input runs out, so it still consumes lazily and only raises at that point. Removed-module surprises (for exampledistutilsin 3.12,asyncore,imp) are another reason to run your tests on the new version with-W error::DeprecationWarningfirst.
Remember this
- 1
Text and paths. Use f-strings (
f"Hi {name}", Topic 3.3) instead of%and.format(). Writef"{x=}"(3.8) to print the name and the value together. Usepathlib.Pathand the/operator (Topic 7.6) instead of gluing strings together withos.path.join. Useremoveprefixandremovesuffix(3.9) instead of slicing by hand. - 2
Data. Use a dataclass (Topic 8.8) with
slots=True, kw_only=True(3.10) instead of a hand-written__init__full of repeated lines.slots=Truesaves memory (Topic 8.12).kw_only=Truemeans every argument must be given by name. UseStrEnum(3.11, Topic 8.9) when the members are strings. - 3
Merge dictionaries with
a | b(3.9) instead of{**a, **b}. Uselist[int]andX | None(3.9, 3.10) instead ofList[int]andOptional[X](Topic 10.2). - 4
Control flow. Use
match(3.10, Topics 2.8 and 10.8) for shape-based branching. Use the walrus:=(3.8, Topic 2.9) to compute a value once and use it. Usezip(..., strict=True)(3.10) so unequal lengths raise an error rather than silently dropping items. Useenumerate(items, start=1)(Topic 2.6) instead of adding 1 yourself. - 5
Two helpers from
itertools(Topic 9.4) arrived too:pairwise(3.10) gives neighbouring pairs andbatched(3.12) gives chunks. - 6
Errors and files. Several failures at once are carried by
ExceptionGroupand handled withexcept*(3.11, Topic 7.3). It was born fromasyncio.TaskGroup(Topic 12.7). Notes can be added to an exception withadd_note(3.11). Read configuration files withtomllib(3.11, Topic 7.8). - 7
Always pass
encoding="utf-8"when opening text files (Topic 3.4 explains encodings). UTF-8 will become the default in Python 3.15. - 8
Recent versions. 3.12 added the
typestatement,def f[T],@override, and nicer f-strings (you can reuse the same quote marks inside). 3.13 added a new coloured interactive shell,copy.replaceandwarnings.deprecated. It also added *experimental* free-threaded builds (Python without the global lock, Topic 12.2) and an experimental JIT (a part that turns hot code into machine code while the program runs). - 9
3.14 added template strings (t-strings), lazily evaluated annotations, an officially supported free-threaded build, and
except A, B:without brackets. Python 3.15 (released on 9 October 2026) makes UTF-8 the default text encoding and adds explicit lazy imports; check the release notes for the current state. - 10
How to upgrade a codebase without breaking it. Know your minimum supported Python: the
requires-pythonsetting inpyproject.toml(Topic 6.7). Only use features from that version or older. - 11
Let Ruff (a tool that finds and fixes style problems, Topic 11.6) modernise the code automatically. Its
UPrules rewriteOptional[int]toint | None,Listtolist,.formatto f-strings and more. Then run your tests (Topic 11.1) and type checker (Topic 10.3). That is what makes a mechanical upgrade safe.
Explain it without notes
Give five modern replacements for old Python idioms.
What does zip(a, b, strict=True) protect you from?
How would you modernise a large codebase safely?
What are template strings and what problem do they address?
Is a | b for dicts a deep merge? How does it differ from a |= b and {**a, **b}?
Practice
Rewrite "Total: %d items" % count with an f-string, for count = 7, and print it.
Merge {"a": 1} and {"a": 2, "b": 3} with the dictionary union operator and print the result. Which value of a wins?
Use zip(strict=True) on [1, 2] and ["a"] inside a try and print the error message.
Rewrite from typing import Optional, List hints Optional[List[str]] in the modern style and print it with typing.get_type_hints for a function using it.
Trade-offs
- ↔
Newest syntax versus reach. The newer the feature, the fewer environments run it. Your
requires-pythonis the cap. - ↔
Rewriting versus leaving alone. Mechanical upgrades are cheap with a tool and tests; hand rewrites of working code carry risk for small gain.
- ↔
Experimental builds. Free-threading and the JIT can bring speed, but are younger; adopt them deliberately, with measurements.
Done when you can
Done when you can rewrite old string formatting, dict merging and type hints in modern style.
Done when you can name what 3.9, 3.10, 3.11, 3.12 and 3.13 each added.
Done when you can explain
zip(strict=True)and the walrus operator.Done when you can set
requires-pythonand a Ruff target and explain why.Done when you can explain what a t-string is and why it is safer than an f-string for SQL.