Modern Python Syntax: 3.8 → 3.13
1 · The lesson
readPython's six releases from 3.8 to 3.13 added a lot of new syntax and features: the walrus, structural pattern matching, dict merge, built-in generics, except*, the Self type, PEP 695 generic syntax, and tomllib. Some only make code shorter or easier to write. Others (match/case, exception groups) change how you structure code.
This lesson goes through the additions that change how idiomatic Python looks today, release by release. Each one shows the version that added it, so you know what you can use with the oldest Python version you support.
1. 3.8: The Walrus Operator :=
:= assigns and evaluates in one expression. This is a recap from conditionals, but it is worth seeing in real code:
import re # WITHOUT walrus: the search runs twice, or match lives outside the if match = re.search(r"id=(\d+)", text) if match: user_id = match.group(1) # WITH walrus: one expression both assigns and tests 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:
import io f = io.StringIO("first line\nsecond line\n") # any open file works the same way # Read lines until the end of the file, in one expression while line := f.readline(): print(line.rstrip())
setup added so this can run · defines line
# 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,) line = _AutoMock('line')
The walrus is useful when you would otherwise compute or call a value twice. It is not useful when the assignment is on its own line anyway. On its own line, x := 5 is not even allowed without parentheses. Write x = 5.
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 will see this in standard library signatures (dict.pop, str.replace) more often than you write it. It is useful when you want to be able to rename a parameter later without breaking callers.
2. 3.8: F-String Debug Syntax f"{x=}"
This is a recap 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 = prints the expression text and the value. You can debug with print without typing the variable name twice.
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 mergeIt reads like set union. {**a, **b} still works, but | is easier to read, so use | in new code.
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 details.
4. 3.10: Structural Pattern Matching
The biggest syntactic addition since async/await. match/case does structural matching: it destructures values instead of only 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 · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'x, y = 42, [1, 2, 3]\nprint(f"{x=}") # x=42\nprint(f"{y=}") # y=[1, 2, 3]\nprint(f"{x + 1=}") # x + 1=43', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
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')
One pattern checks the type and destructures the value. This is the Pythonic replacement for the Visitor pattern from patterns.
Guard clauses: an 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: a common trap. A bare name in a pattern is a capture: it binds a value, 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: ... # a literal: matches the int 1 case Colours.RED: ... # a dotted name: matches the constant case RED: ... # ⚠️ CAPTURE: binds `RED` to colour, always matches
setup added so this can run · defines colour, Colours
# 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,) colour = _AutoMock('colour') Colours = _AutoMock('Colours')
If you put the bare-name case before the other cases, Python refuses to compile the match at all: SyntaxError: name capture 'RED' makes remaining patterns unreachable. As the last case, it compiles, but it matches every value and overwrites RED.
This is the most common match/case mistake. Bare names are captures. To check against a constant, qualify it (module.NAME or Class.NAME).
Union pattern X | Y: match either one:
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] still works, but in new code use the operator:
def parse(value: int | str | None) -> int | None: ...
The 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 · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'x, y = 42, [1, 2, 3]\nprint(f"{x=}") # x=42\nprint(f"{y=}") # y=[1, 2, 3]\nprint(f"{x + 1=}") # x + 1=43', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
See typehints for migration notes.
Parenthesised context managers (also 3.10): you can split several with items over several 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 you only need parentheses.
6. 3.11: Exception Groups and except*
Recap from exceptions. Sometimes several errors happen at the same time, for example in a TaskGroup of async tasks, or in a validation step that should report every failure. ExceptionGroup collects them, and except* handles 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, removes the matched ones, and lets the unmatched ones propagate. This is the right tool when many parallel tasks can fail.
7. 3.11: asyncio.TaskGroup and asyncio.timeout()
The modern async patterns from async:
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()
It is cleaner than asyncio.wait_for, and it works correctly inside a TaskGroup.
8. 3.11: Self Type
Fluent builders return self. Before 3.11 you typed it with a TypeVar, but 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 benefit 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: open in binary mode config = tomllib.load(f) print(config["project"]["name"])
It is read-only: tomllib parses TOML, it does not write it. For writing, you still need tomli-w or tomlkit. But for the most common case (reading a config file), you need no extra dependencies.
10. 3.12: type Statement and PEP 695 Generics
Python 3.12 added a shorter syntax for generics and type aliases:
type Vector = list[float] # a type alias, as its own statement def first[T](items: list[T]) -> T: # a generic function, with no TypeVar import return items[0] class Stack[T]: # a generic class, with 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"), and inherited from Generic[T]. PEP 695 replaces all of that. Other languages, such as Scala and Go, also put type parameters in square brackets after the name.
The old TypeVar form still works, and you will see it in libraries that support older Python versions. New code on 3.12+: use the new syntax.
11. 3.12: Smarter F-Strings
The f-string parser was rewritten. Multi-line expressions, reusing the same quote character inside, and comments inside the expression all work now:
name = "Hedy" items = ["a", "b"] print(f"hello { name.upper() # multi-line expressions, comments allowed }!") # The same quotes nested inside: a SyntaxError before 3.12 print(f"{"hello"}") # legal in 3.12+ print(f"{["a", "b"][0]}") # legal in 3.12+
Don't overuse this: long expressions inside f-strings are still hard to read. But the old rule that forced you to switch quote characters inside (as in f"{d['key']}") is gone.
@override (also 3.12): a typing decorator that says a method overrides a parent method:
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, so the type checker reports it return "woof"
At runtime it does almost nothing, but it is useful for static analysis. @override lets the type checker catch "you renamed the parent method and forgot to update the child" before the code is released.
12. 3.13: Free-Threading and the JIT (Experimental)
Two big experimental additions. Both are off by default, and both are aimed at future versions.
- No-GIL builds (PEP 703). A
python3.13tbinary (or--disable-gilconfigure flag) removes the Global Interpreter Lock, so threads can run truly in parallel. The problem: C extensions need updates to be thread-safe without the GIL. Pure-Python code mostly works. Some libraries already support it, and others are still being updated.
- JIT compiler (PEP 744). A copy-and-patch JIT, turned on with a build flag. The speedups in 3.13 are small, but the Python developers plan to build on it in later versions.
For now, don't restructure your code around either of them, but know that they exist.
Stable improvements in 3.13 include a better 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 causing problems for 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 says it supports 3.10. If you only test on the latest version, your CI will not show the problem.
2. Add the older versions to CI. Test on 3.10, 3.11, 3.12, 3.13, or whatever versions you support. If a version isn't tested in CI, assume it doesn't work.
3. Use from __future__ import annotations at the top of every module. It delays the evaluation of type hints, so newer hint syntax works on older interpreters (with some limits).
4. Use a linter that knows your target version. For example, set target-version = "py310" in ruff, so it can warn you about code that your oldest supported version can't run.
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 matters more than small improvements.
6. Read the "What's New" for every version. It takes about twenty minutes per release, and it keeps your Python up to date.
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. If it makes a comprehension harder to read than the long form, use the long form.
2. Expecting a *rest capture to be a view of the original.
match data:
case [head, *rest]:
rest.append("extra") # changes rest only: it is a new list, data is untouched 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')
A star capture is a copy: rest is a new list, so changing it never affects data. (Plain captures like head are the same objects that are in data, though.)
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 is not a switch. It does structural destructuring. Bare names capture. They don't compare:
DEFAULT = "x" match value: case DEFAULT: # ⚠️ binds DEFAULT to value, so it 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. This is deliberate: TOML files are always UTF-8, and the library decodes them itself.
🎯 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 returns (200, {"users": [...]}) # TODO 2: GET /users/<id>, capturing the id in a pattern variable # TODO 3: POST /users with a dict body containing a "name" key # TODO 4: DELETE /users/<id> # TODO 5: anything else returns (404, {"error": "not found"}) case _: ... # replace this with your cases 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 only the keys you mention.
Hint 2: Capture the id with a split
The simplest 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] # drop empty strings 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)}): # str(name) also checks the type return (201, {"id": "new", "name": name}) case ("POST", ["users"], _): # body is missing or 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 is a chain of conditions likeif request["method"] == "GET" and request["path"].startswith("/users/"), andmatchwas added to replace code like that. ["users", user_id]is a list pattern that captures the second element. The path is structured data, so treat it as such instead of comparing strings.{"name": str(name)}combines a dict pattern with a class pattern. It matches a dict whose"name"value is a string, and binds the string toname. This checks and destructures the value in one step.- The narrower POST clause comes first. Patterns are tried top-to-bottom, so the specific case (valid body) must come before the catch-all (
("POST", ["users"], _)), or the wildcard would match first. - No capture mistakes. Every value compared in this
matchis a string literal ("GET","users") or inside a list pattern, so there are 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 (off by default). Better 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 splits into application areas (web frameworks, data engineering, ML). Each one is a separate path that builds on what you have learned.
Found this useful? Share it with a friend who’s learning Python.