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

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

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

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

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

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

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

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

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

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')

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:

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

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

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] still works, but in new code use the operator:

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

The 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 · 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:

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

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

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()

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:

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

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

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

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

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

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

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

python
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 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 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 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]    # 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 like if request["method"] == "GET" and request["path"].startswith("/users/"), and match was 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 to name. 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 match is 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 | 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 (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.