post python · 2024-11-03 · 5 min read

Python syntactic sugar from 3.10–3.13 that actually changed how I write code

#python#language#learning

Python’s last four releases have shipped more sugar than the previous decade combined. Some of it I use daily. Some of it I tried, decided was a fad, and went back to the old way. This post is the short list of features that actually changed how I write Python in production, with code samples for each.

1. Pattern matching (3.10) — for shape-discrimination, not as a switch statement

The classic mistake with match is using it as a glorified if/elif chain. That’s not what it’s for. The real win is pattern-matching on the structure of data.

# bad: just a switch
match status:
case "ok":
handle_ok()
case "error":
handle_error()
case _:
handle_unknown()
# good: shape discrimination on a parsed JSON payload
match event:
case {"kind": "click", "x": x, "y": y, "button": "left"}:
on_left_click(x, y)
case {"kind": "click", "x": x, "y": y, "button": "right"}:
on_right_click(x, y)
case {"kind": "scroll", "delta": delta}:
on_scroll(delta)
case {"kind": "key", "code": code}:
on_key(code)
case _:
log.warn("unrecognised event %r", event)

The second form is unbeatable for parser-shaped code, agent message routers, and anywhere you’d otherwise write a tower of isinstance + dict-key checks.

It also works on dataclasses with positional patterns:

from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
centre: Point
radius: float
shape = Circle(Point(0, 0), 5)
match shape:
case Circle(centre=Point(x=0, y=0), radius=r):
print(f"unit-ish circle at origin, r={r}")
case Circle(centre=c, radius=r):
print(f"circle at {c}, r={r}")
case Point(x=x, y=y):
print(f"point ({x}, {y})")

Where I reach for it: parsing LLM tool-call payloads, routing agentic events, walking an AST.

2. Self-documenting f-strings, the = modifier (3.8 originally, but everyone is finally using it)

n = 42
mode = "agentic"
print(f"{n=}") # n=42
print(f"{mode=!r}") # mode='agentic'
print(f"{n * 2 + 1=}") # n * 2 + 1=85

This replaces print(f"n={n}"). Reads better. One character less to typo. The !r modifier tells f-string to use repr() instead of str(), which is what you want for debugging strings.

Where I reach for it: every print-debug session. It’s also great in test failure messages.

3. match + | for union patterns

def classify(x):
match x:
case int() | float():
return "numeric"
case str() | bytes():
return "stringy"
case list() | tuple() | set():
return "iterable"
case None:
return "nothing"
case _:
return "unknown"

Cleaner than a chain of isinstance checks. Note: int() here is a type pattern, not a constructor call.

4. Exception groups (3.11)

def fetch_all(urls):
errors = []
for url in urls:
try:
fetch(url)
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup("some fetches failed", errors)

Now consumers can handle multiple errors at once with except*:

try:
fetch_all(urls)
except* TimeoutError as eg:
print(f"timeouts: {len(eg.exceptions)}")
except* ConnectionError as eg:
print(f"network errors: {len(eg.exceptions)}")

The except* syntax filters the group by exception type, leaving any non-matching ones to propagate. This finally gives concurrent code a sane error model: asyncio.gather and TaskGroup raise an ExceptionGroup, you handle each kind explicitly.

5. asyncio.TaskGroup (3.11) — kills asyncio.gather for me

import asyncio
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("https://a"))
t2 = tg.create_task(fetch("https://b"))
t3 = tg.create_task(fetch("https://c"))
# all three awaited at the end of the with-block
print(t1.result(), t2.result(), t3.result())

If any task fails, the rest are cancelled, and the failures come out as an ExceptionGroup. No more “I forgot to call asyncio.gather with return_exceptions=True and now one error has cancelled my whole pipeline silently.”

Where I reach for it: literally every async function in agentic code where I’m running parallel tool calls.

6. PEP 695 generic syntax (3.12)

Before 3.12:

from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def push(self, x: T) -> None: ...
def pop(self) -> T: ...

After:

class Stack[T]:
def push(self, x: T) -> None: ...
def pop(self) -> T: ...

Functions too:

def first[T](xs: list[T]) -> T:
return xs[0]

It is just less ceremony. The TypeVar import goes away, the visual noise goes away, the file gets shorter. After two months of the new syntax, going back to read pre-3.12 generic code feels archaic.

7. @override decorator (3.12)

from typing import override
class Base:
def render(self) -> str:
return "base"
class Child(Base):
@override
def render(self) -> str:
return "child"

Without @override, if you rename Base.renderBase.draw, Child.render silently becomes a new method instead of an overridden one. Now type checkers catch this immediately because @override declares “I’m intentionally overriding a parent method.” If the parent method doesn’t exist, it errors.

It is the kind of safety net you don’t notice until you’ve shipped a refactor that broke a subclass nobody re-checked.

8. The walrus, finally widely-used (3.8 originally, but adoption took years)

# without walrus
chunks = []
chunk = file.read(1024)
while chunk:
chunks.append(chunk)
chunk = file.read(1024)
# with walrus
chunks = []
while chunk := file.read(1024):
chunks.append(chunk)

The walrus operator (:=) assigns and evaluates in one step. Where I reach for it most: regex matching inside conditions:

import re
# without
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag)
if m:
major, minor, patch = m.groups()
do_something(major, minor, patch)
# with
if m := re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag):
major, minor, patch = m.groups()
do_something(major, minor, patch)

One less line, one less name to spell. Fights tend to break out around this one — purists call it noise. I find it removes friction in exactly the places where my brain naturally wants to combine the test and the bind.

What I do not use

Some new features I tried and dropped:

A word on adoption

Most of this is gated by your minimum Python version. If you’re stuck on 3.9 or earlier, none of it lands. My rule: when 3.11 hit security-fix-only status (October 2024 in some distros), I bumped projects to 3.12 minimum. That unlocked PEP 695 generics, the @override decorator, and TaskGroup everywhere — three of the six features above.

If you can move the floor, move it. The cost is a one-off CI matrix update; the gain is permanent reduction in the line-count of every typed function you write thereafter.