Modern Python Syntax: 3.8 → 3.13
1 · The lesson
readPython's last six releases added more sugar than the previous decade combined: the walrus, structural pattern matching, dict merge, builtin generics, except*, the Self type, PEP 695 generic syntax, and tomllib. Some are pure ergonomics. Others (match/case, exception groups) change how you structure code.
This lesson walks through the additions that actually change the way idiomatic Python looks today. By release. With the version each landed in, so you know what's safe to use against your minimum supported interpreter.
1. 3.8 — The Walrus Operator :=
:= assigns and evaluates in one expression. Recap from conditionals, but worth seeing in its real habitat:
import re # WITHOUT walrus — match runs twice or lives outside the if match = re.search(r"id=(\d+)", text) if match: user_id = match.group(1) # WITH walrus — one expression, scoped intent if match := re.search(r"id=(\d+)", text): user_id = match.group(1)
setup added so this can run · defines text
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) text = _AutoMock('text')
Same pattern with while:
# Stream lines until EOF in one expression while line := f.readline(): process(line)
setup added so this can run · defines process, line, f
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def process(*_a, **_kw): print('-> process() called') return _AutoMock('process()') line = _AutoMock('line') f = _AutoMock('f')
The walrus pays for itself when you'd otherwise compute or call a value twice. It pays nothing when the assignment is on its own line anyway — x := 5 outside an expression is just x = 5 with extra punctuation.
Positional-only parameters (also 3.8) — / in a signature marks every parameter to its left as positional-only:
def divmod(a, b, /): # a, b cannot be passed by keyword return a // b, a % b
setup added so this can run · defines a, b
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) a = _AutoMock('a') b = _AutoMock('b')
You'll see this in stdlib signatures (dict.pop, str.replace) more than you'll write it. Useful when you want to rename a parameter without breaking callers.
2. 3.8 — F-String Debug Syntax f"{x=}"
Still the best line of Python added in a decade. From fstrings:
x, y = 42, [1, 2, 3] print(f"{x=}") # x=42 print(f"{y=}") # y=[1, 2, 3] print(f"{x + 1=}") # x + 1=43
The = includes the expression text and the value. Print-debugging without the print-debugging boilerplate.
3. 3.9 — Dict Merge | and |=
Before 3.9, merging two dicts took a comprehension or {**a, **b}. Now it's an operator:
defaults = {"timeout": 30, "retries": 3}
overrides = {"timeout": 60, "verbose": True}
config = defaults | overrides # right side wins on conflict
# {'timeout': 60, 'retries': 3, 'verbose': True}
defaults |= overrides # in-place mergeReads like set union. The {**a, **b} ceremony is dead. Use |.
Built-in generics (also 3.9) — list[int], dict[str, int], tuple[int, ...] work directly as type hints. The typing.List, typing.Dict imports are deprecated for hinting:
def parse(rows: list[dict[str, int]]) -> dict[str, list[int]]: ...
See typehints for the full story.
4. 3.10 — Structural Pattern Matching
The biggest syntactic addition since async/await. match/case does structural matching — destructuring values, not comparing them with ==.
def describe(point): match point: case (0, 0): return "origin" case (0, y): return f"on the y-axis at {y}" case (x, 0): return f"on the x-axis at {x}" case (x, y): return f"at ({x}, {y})" case _: return "not a 2D point" print(describe((0, 0))) # origin print(describe((3, 0))) # on the x-axis at 3 print(describe((4, 5))) # at (4, 5)
setup added so this can run · defines y, x
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) y = _AutoMock('y') x = _AutoMock('x')
The patterns destructure. (0, y) matches "a 2-tuple whose first element equals 0, binding the second to y." (x, y) matches any 2-tuple, binding both. _ is the wildcard.
Class patterns match by type and attributes:
from dataclasses import dataclass @dataclass class Circle: radius: float @dataclass class Rect: width: float; height: float def area(shape): match shape: case Circle(radius=r): return 3.14159 * r * r case Rect(width=w, height=h): return w * h case _: raise TypeError(shape)
setup added so this can run · defines r, w, h
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) r = _AutoMock('r') w = _AutoMock('w') h = _AutoMock('h')
Type-check + destructure in one pattern. This is the Pythonic replacement for the Visitor pattern from patterns.
Guard clauses — extra if after the pattern:
match user:
case {"role": "admin"}:
grant_all()
case {"role": "user", "verified": True}:
grant_standard()
case {"role": "user"} if user.get("trial"):
grant_trial()
case _:
deny() setup added so this can run · defines user, grant_all, grant_standard, grant_trial, deny
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) user = _AutoMock('user') def grant_all(*_a, **_kw): print('-> grant_all() called') return _AutoMock('grant_all()') def grant_standard(*_a, **_kw): print('-> grant_standard() called') return _AutoMock('grant_standard()') def grant_trial(*_a, **_kw): print('-> grant_trial() called') return _AutoMock('grant_trial()') def deny(*_a, **_kw): print('-> deny() called') return _AutoMock('deny()')
Capture vs literal — the trap. A bare name in a pattern is a capture — it binds, it doesn't compare. To match a literal, use a dotted name (constant), a literal, or case True/False/None:
RED = 1 match colour: case 1: ... # literal — matches the int 1 case RED: ... # ⚠️ CAPTURE — binds `RED` to colour, always matches case Colours.RED: ... # dotted — matches the constant
This is the single biggest match/case gotcha. Bare names are captures. To check against a constant, qualify it (module.NAME or Class.NAME).
Union pattern X | Y — match either:
match status:
case "ok" | "success":
...
case "err" | "failed":
... setup added so this can run · defines status
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) status = _AutoMock('status')
5. 3.10 — Union Syntax int | str
Union[int, str] is dead. Use the operator:
def parse(value: int | str | None) -> int | None: ...
Same change makes isinstance(x, int | str) work:
isinstance(x, int | str) # True if x is int or str
setup added so this can run · defines x
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) x = _AutoMock('x')
See typehints for migration notes.
Parenthesised context managers (also 3.10) — multiple with items spread cleanly over lines:
with ( open("in.txt") as src, open("out.txt", "w") as dst, timer("copy"), ): dst.write(src.read())
setup added so this can run · defines timer
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def timer(*_a, **_kw): print('-> timer() called') return _AutoMock('timer()')
Before 3.10, you nested withs or used contextlib.ExitStack. Now it's just punctuation.
6. 3.11 — Exception Groups and except*
Recap from exceptions. When multiple errors happen in parallel — a TaskGroup of async tasks, a validation pass that wants to surface every failure — ExceptionGroup bundles them and except* dispatches each type:
try: run_concurrent_tasks() except* ValueError as eg: for e in eg.exceptions: log.warning("value error: %s", e) except* TimeoutError as eg: log.warning("%d tasks timed out", len(eg.exceptions))
setup added so this can run · defines run_concurrent_tasks, log
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def run_concurrent_tasks(*_a, **_kw): print('-> run_concurrent_tasks() called') return _AutoMock('run_concurrent_tasks()') log = _AutoMock('log')
except* matches the type anywhere in the (possibly nested) group, strips matched ones out, and lets unmatched ones bubble. This is the right primitive for fan-out failures.
7. 3.11 — asyncio.TaskGroup and asyncio.timeout()
The modern async patterns from async, in syntax form:
import asyncio async def fetch_all(urls): async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(fetch(u)) for u in urls] return [t.result() for t in tasks]
setup added so this can run · defines tg, fetch
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) tg = _AutoMock('tg') def fetch(*_a, **_kw): print('-> fetch() called') return _AutoMock('fetch()')
TaskGroup waits for every task on exit. If any task raises, the group cancels the rest and re-raises as an ExceptionGroup. No more asyncio.gather(*tasks, return_exceptions=True) followed by manual filtering.
asyncio.timeout() is the modern timeout context manager:
async with asyncio.timeout(5): result = await slow_api_call()
Cleaner than asyncio.wait_for, and composes properly inside a TaskGroup.
8. 3.11 — Self Type
Fluent builders return self. Before 3.11 you typed it with a TypeVar; now Self does it directly:
from typing import Self class QueryBuilder: def __init__(self): self._filters = [] def where(self, **kw) -> Self: self._filters.append(kw) return self def order_by(self, field: str) -> Self: self._order = field return self def build(self) -> str: return "SELECT * WHERE " + " AND ".join(map(str, self._filters)) q = QueryBuilder().where(status="active").order_by("created_at")
setup added so this can run · defines kw
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) kw = _AutoMock('kw')
The win is for subclasses. Self resolves to the actual class in subclass methods, so MyBuilder().where(...) is typed as MyBuilder, not QueryBuilder.
9. 3.11 — tomllib in the Standard Library
TOML reading without a third-party dependency:
import tomllib with open("pyproject.toml", "rb") as f: # NOTE: binary mode config = tomllib.load(f) print(config["project"]["name"])
Read-only — tomllib parses, it does not serialise. For writing, you still need tomli-w or tomlkit. But for the 95% case (read a config file), zero dependencies.
10. 3.12 — type Statement and PEP 695 Generics
The cleanest generic syntax Python has ever shipped:
type Vector = list[float] # type alias — first-class statement def first[T](items: list[T]) -> T: # generic function — no TypeVar import return items[0] class Stack[T]: # generic class — no Generic[T] base def __init__(self) -> None: self._items: list[T] = [] def push(self, x: T) -> None: self._items.append(x) def pop(self) -> T: return self._items.pop()
setup added so this can run · defines T
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) T = _AutoMock('T')
Before 3.12, you imported TypeVar, declared T = TypeVar("T"), inherited Generic[T]. PEP 695 collapses all of it. The square-bracket-after-name syntax is the same shape TypeScript and most modern languages use.
The old TypeVar form still works and you'll see it in libraries supporting older Pythons. New code on 3.12+: use the new syntax.
11. 3.12 — Smarter F-Strings
The parser was rewritten. Multi-line, nested matching quotes, and embedded comments all work:
name = "Hedy" items = ["a", "b"] print(f"hello { name.upper() # multi-line expressions, comments allowed }!") # Nested same-character quotes — previously a SyntaxError print(f"{"hello"}") # legal in 3.12+ print(f"{["a", "b"][0]}") # legal in 3.12+
Don't go wild — long expressions inside f-strings are still hard to read. But the parser limitations that made f"{d['key']}" annoying are gone.
@override (also 3.12) — a typing decorator that asserts a method overrides a parent:
from typing import override class Animal: def speak(self) -> str: ... class Dog(Animal): @override def speak(self) -> str: # ✓ parent has speak return "woof" @override def spaek(self) -> str: # ✗ typo — type checker flags it return "woof"
The runtime treats it as a no-op; the value is in static analysis. A @override catches "you renamed the parent method and forgot to update the child" before it ships.
12. 3.13 — Free-Threading and the JIT (Experimental)
Two big experimental additions. Both off by default; both are forward-looking.
- No-GIL builds (PEP 703). A
python3.13tbinary (or--disable-gilconfigure flag) drops the Global Interpreter Lock, letting threads run truly in parallel. The catch: most C extensions need updates to be thread-safe. Pure-Python code is mostly fine;numpy/pandas/etc. are in the middle of migration.
- JIT compiler (PEP 744). A copy-and-patch JIT, behind a build flag. Small speedups today; the architecture is the foundation for larger wins in 3.14+.
For now, treat both as "Python is positioning to be properly fast and properly parallel within two more releases." Don't restructure your code around either yet, but know they're coming.
Stable improvements in 3.13 include cleaner REPL (multi-line edit, history search), copy.replace as a generic shallow-copy-with-changes (works on dataclasses, named tuples), and typing improvements (TypeIs, defaults for type parameters).
13. Adoption Strategy
Six rules for using new syntax without breaking your team:
1. Know your minimum supported Python. Check pyproject.toml (requires-python = ">=3.11"). Don't use 3.12 syntax in a library that claims 3.10 support — your CI lies if you only test on the latest.
2. Add the older versions to CI. Test on 3.10, 3.11, 3.12, 3.13 — whatever your support matrix says. If it's not in CI it doesn't work.
3. Use from __future__ import annotations at the top of every module. Defers evaluation of type hints, so newer hint syntax works on older interpreters (within reason).
4. Use a linter that knows your target. ruff with target-version = "py310" flags walrus in a 3.7-targeted codebase.
5. Don't mix old and new in the same file. If you've adopted X | Y for unions, don't leave a stray Union[X, Y] two lines down. Consistency over micro-cleverness.
6. Read the "What's New" for every version. Twenty minutes per release. The cumulative payoff is the difference between writing 2018 Python and 2026 Python.
Common Mistakes
1. Walrus inside a comprehension where a plain for would be clearer.
# Clever, hard to read result = [(y := f(x), y * 2) for x in xs] # Obvious result = [] for x in xs: y = f(x) result.append((y, y * 2))
setup added so this can run · defines xs, f
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) xs = ["alpha", "beta", "gamma"] def f(*_a, **_kw): print('-> f() called') return _AutoMock('f()')
The walrus is for expressions. The moment it makes a comprehension less readable than the long form, undo it.
2. Mutating a captured variable in a match pattern.
match data:
case [head, *rest]:
rest.append("extra") # mutates the bound name; surprising semantics setup added so this can run · defines data, rest
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) data = _AutoMock('data') rest = _AutoMock('rest')
Pattern bindings share the underlying list. Treat them as read-only.
3. Mixing Union[X, Y] and X | Y in the same module. Pick one. New code: X | Y everywhere.
4. Treating match/case like switch. It's not a switch — it's structural destructuring. Bare names CAPTURE; they don't compare:
DEFAULT = "x" match value: case DEFAULT: # ⚠️ binds DEFAULT to value — matches everything! ...
setup added so this can run · defines value
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) value = _AutoMock('value')
Use case "x": (literal) or case module.DEFAULT: (dotted constant).
5. Using new syntax in code that must run on older interpreters. match/case doesn't even parse on 3.9. A 3.12 generic type alias is a SyntaxError on 3.11. If your pyproject.toml says requires-python = ">=3.9", you can't use either.
6. Forgetting tomllib opens in binary mode. open("file.toml", "rb"). Text mode is a TypeError. The choice was deliberate — TOML's encoding is fixed at UTF-8 and the library does its own decoding.
🎯 Your Turn — A Mini Router with match/case
Write route(request) that dispatches an HTTP-style request dict using match/case. Support:
GET /users→ list usersGET /users/<id>→ fetch one userPOST /userswith a JSON body → create a userDELETE /users/<id>→ delete- Anything else → 404
Each branch returns a (status_code, body) tuple.
def route(request: dict): # request shape: {"method": "GET", "path": "/users/42", "body": None} match request: # TODO 1: GET /users -> (200, {"users": [...]}) # TODO 2: GET /users/<id> — extract the id as a captured pattern variable # TODO 3: POST /users with a dict body containing a "name" key # TODO 4: DELETE /users/<id> # TODO 5: default -> (404, {"error": "not found"}) ... print(route({"method": "GET", "path": "/users", "body": None})) print(route({"method": "GET", "path": "/users/42", "body": None})) print(route({"method": "POST", "path": "/users", "body": {"name": "Hedy"}})) print(route({"method": "DELETE", "path": "/users/42", "body": None})) print(route({"method": "PATCH", "path": "/health", "body": None}))
Hint 1 — Destructure the dict
case {"method": "GET", "path": "/users"} matches a dict containing those keys with those exact values. Extra keys in the dict are fine — pattern matching on dicts checks the keys you mention, not all of them.
Hint 2 — Capture the id with a split
The cleanest way is to splitpath first and match on the resulting list. Pre-compute parts = request["path"].split("/")[1:] before the match, then match (request["method"], parts) on a tuple. case ("GET", ["users", user_id]) captures user_id.
Show full solution
def route(request: dict): """Dispatch an HTTP-style request to a (status, body) response.""" method = request["method"] parts = [p for p in request["path"].split("/") if p] # strip empties body = request.get("body") match (method, parts, body): case ("GET", ["users"], _): return (200, {"users": [{"id": 1, "name": "Hedy"}]}) case ("GET", ["users", user_id], _): return (200, {"id": user_id, "name": "user-" + user_id}) case ("POST", ["users"], {"name": str(name)}): # guard via type pattern return (201, {"id": "new", "name": name}) case ("POST", ["users"], _): # body missing/invalid return (400, {"error": "name required"}) case ("DELETE", ["users", user_id], _): return (204, None) case _: return (404, {"error": "not found"}) # Exercise the routes for req in [ {"method": "GET", "path": "/users", "body": None}, {"method": "GET", "path": "/users/42", "body": None}, {"method": "POST", "path": "/users", "body": {"name": "Hedy"}}, {"method": "POST", "path": "/users", "body": {}}, {"method": "DELETE", "path": "/users/42", "body": None}, {"method": "PATCH", "path": "/health", "body": None}, ]: print(req["method"], req["path"], "→", route(req)) # GET /users → (200, {'users': [{'id': 1, 'name': 'Hedy'}]}) # GET /users/42 → (200, {'id': '42', 'name': 'user-42'}) # POST /users → (201, {'id': 'new', 'name': 'Hedy'}) # POST /users → (400, {'error': 'name required'}) # DELETE /users/42 → (204, None) # PATCH /health → (404, {'error': 'not found'})
setup added so this can run · defines user_id, name
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) user_id = _AutoMock('user_id') name = _AutoMock('name')
What makes this idiomatic:
- Tuple-matching on
(method, parts, body)keeps every route on one line. The alternative — nestedif request["method"] == "GET" and request["path"].startswith("/users/")— is whatmatchwas added to kill. ["users", user_id]is a list pattern that captures the second element. The path is structured data; treat it as such instead of comparing strings.{"name": str(name)}combines a dict pattern with a type pattern. It matches a dict whose"name"value is a string, binding the string toname. This is the right way to validate-and-destructure in one step.- The narrower POST clause comes first. Patterns are tried top-to-bottom; the specific case (valid body) must come before the catch-all (
("POST", ["users"], _)) or the wildcard would eat it. - No "default value" capture pitfall. Every literal in this
matchis either a string literal ("GET","users") or a list literal — no bare-name traps. The variables (user_id,name) are captures by design.
In a real framework, you'd register routes with a decorator and build a trie or a regex table for speed. But for a small JSON-RPC service, a request dispatcher, or a state machine, match/case is shorter, more obvious, and harder to break than the equivalent if-elif chain.
What You Learned
- 3.8 — Walrus
:=for assign-in-expression.f"{x=}"debug. Positional-only/. - 3.9 —
a | bfor dict merge,a |= bfor in-place.list[int]instead ofList[int]. - 3.10 —
match/casefor structural matching (with the capture vs literal trap).X | Yfor union types. Parenthesised multi-linewith. - 3.11 —
ExceptionGroupandexcept*.asyncio.TaskGroup,asyncio.timeout().Selffor fluent returns.tomllibstdlib. - 3.12 — PEP 695 generic syntax:
type Vec = list[float],def first[T](xs: list[T]) -> T:,class Stack[T]:. Smarter f-strings.@overridefromtyping. - 3.13 — Experimental no-GIL builds and JIT (background, off by default). Cleaner REPL.
copy.replace. - Adoption: know your minimum Python, run it in CI, use
from __future__ import annotations, don't mix old and new in the same module, read every release's "What's New."
You've reached the end of the Advanced path. From here, the curriculum branches into application domains — web frameworks, data engineering, ML — each one a separate path that builds on everything you've just learned.