PythonMastery
advanced 18 min read · lesson 9 of 9 in Python Advanced

Modern Python Syntax: 3.8 → 3.13

1 · The lesson

read

Python'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:

python
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:

python
# 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:

python
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:

python
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:

python
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 merge

Reads 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:

python
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 ==.

python
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:

python
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:

python
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:

python
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:

python
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:

python
def parse(value: int | str | None) -> int | None:
    ...

Same change makes isinstance(x, int | str) work:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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.13t binary (or --disable-gil configure 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.

python
# 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.

python
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:

python
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 users
  • GET /users/<id> → fetch one user
  • POST /users with a JSON body → create a user
  • DELETE /users/<id> → delete
  • Anything else → 404

Each branch returns a (status_code, body) tuple.

python
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 split path 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
python
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 — nested if request["method"] == "GET" and request["path"].startswith("/users/") — is what match was 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 to name. 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 match is 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 | b for dict merge, a |= b for in-place. list[int] instead of List[int].
  • 3.10 — match/case for structural matching (with the capture vs literal trap). X | Y for union types. Parenthesised multi-line with.
  • 3.11 — ExceptionGroup and except*. asyncio.TaskGroup, asyncio.timeout(). Self for fluent returns. tomllib stdlib.
  • 3.12 — PEP 695 generic syntax: type Vec = list[float], def first[T](xs: list[T]) -> T:, class Stack[T]:. Smarter f-strings. @override from typing.
  • 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.