Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~34 min· topic 9 of 9

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.toml saying 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.

main.pywhole filepython
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=3

02Dictionaries, 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.

main.pywhole filepython
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.

pyproject.tomlwhole filetoml
[project]
requires-python = ">=3.12"

[tool.ruff]
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "UP"]

Try it yourself

  1. 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 returns default for an empty list. Use list[int], int | None and a conditional expression.

  2. 2

    Catch a silent bug

    In the first example, change zip(..., strict=True) to plain zip and note that the error vanishes: the extra item is silently dropped. That is the bug strict=True exists to expose.

  3. 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

Modern idioms in one program Python 3.11+ New tab

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.

Sign in to run this example in your browser.

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'),)
Old and new give the same answer New tab

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.

Sign in to run this example in your browser.

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: 11
New in Python 3.13: copy.replace and deprecated Python 3.13+ New tab

copy.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.

Sign in to run this example in your browser.

Expected output

Config(model='small', temperature=0.0)
Config(model='small', temperature=0.7)
old
DeprecationWarning - use new_api() instead
Python 3.14: template strings and except without brackets Python 3.14+python

Not 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")
What each version addeddiagram
Rendering diagram…

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.

main.pywhole filepython
import copy
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    model: str

print(copy.replace(Config("small"), model="large"))
terminal
$ python3.12 main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 8, in <module>
print(copy.replace(Config("small"), model="large"))
^^^^^^^^^^^^
AttributeError: module 'copy' has no attribute 'replace'

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_extensions for newer typing features on older Python, and from __future__ import annotations to use X | Y in annotations on 3.7-3.9 (it does not help at run time, e.g. in isinstance).

  • ▸

    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 | b is a shallow merge: a nested dict under a repeated key is replaced, not merged, and a |= b mutates a in place (and, unlike |, also accepts an iterable of pairs). tomllib.load needs a file opened in binary mode (open(path, "rb")); loads takes text. A StrEnum member prints as its plain value (str() and f-strings give red), whereas with the older class C(str, Enum) both give C.RED on Python 3.12 and later (before 3.12 the f-string gave red but str() gave C.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 example distutils in 3.12, asyncore, imp) are another reason to run your tests on the new version with -W error::DeprecationWarning first.

Remember this

  1. 1

    Text and paths. Use f-strings (f"Hi {name}", Topic 3.3) instead of % and .format(). Write f"{x=}" (3.8) to print the name and the value together. Use pathlib.Path and the / operator (Topic 7.6) instead of gluing strings together with os.path.join. Use removeprefix and removesuffix (3.9) instead of slicing by hand.

  2. 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=True saves memory (Topic 8.12). kw_only=True means every argument must be given by name. Use StrEnum (3.11, Topic 8.9) when the members are strings.

  3. 3

    Merge dictionaries with a | b (3.9) instead of {**a, **b}. Use list[int] and X | None (3.9, 3.10) instead of List[int] and Optional[X] (Topic 10.2).

  4. 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. Use zip(..., strict=True) (3.10) so unequal lengths raise an error rather than silently dropping items. Use enumerate(items, start=1) (Topic 2.6) instead of adding 1 yourself.

  5. 5

    Two helpers from itertools (Topic 9.4) arrived too: pairwise (3.10) gives neighbouring pairs and batched (3.12) gives chunks.

  6. 6

    Errors and files. Several failures at once are carried by ExceptionGroup and handled with except* (3.11, Topic 7.3). It was born from asyncio.TaskGroup (Topic 12.7). Notes can be added to an exception with add_note (3.11). Read configuration files with tomllib (3.11, Topic 7.8).

  7. 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. 8

    Recent versions. 3.12 added the type statement, 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.replace and warnings.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. 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. 10

    How to upgrade a codebase without breaking it. Know your minimum supported Python: the requires-python setting in pyproject.toml (Topic 6.7). Only use features from that version or older.

  11. 11

    Let Ruff (a tool that finds and fixes style problems, Topic 11.6) modernise the code automatically. Its UP rules rewrite Optional[int] to int | None, List to list, .format to 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

01

Give five modern replacements for old Python idioms.

02

What does zip(a, b, strict=True) protect you from?

03

How would you modernise a large codebase safely?

04

What are template strings and what problem do they address?

05

Is a | b for dicts a deep merge? How does it differ from a |= b and {**a, **b}?

Practice

01

Rewrite "Total: %d items" % count with an f-string, for count = 7, and print it.

02

Merge {"a": 1} and {"a": 2, "b": 3} with the dictionary union operator and print the result. Which value of a wins?

03

Use zip(strict=True) on [1, 2] and ["a"] inside a try and print the error message.

04

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-python is 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-python and 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.