post python · 2024-11-03 · 5 min read
Python syntactic sugar from 3.10–3.13 that actually changed how I write code
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 switchmatch status: case "ok": handle_ok() case "error": handle_error() case _: handle_unknown()
# good: shape discrimination on a parsed JSON payloadmatch 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
@dataclassclass Point: x: float y: float
@dataclassclass 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 = 42mode = "agentic"
print(f"{n=}") # n=42print(f"{mode=!r}") # mode='agentic'print(f"{n * 2 + 1=}") # n * 2 + 1=85This 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.render → Base.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 walruschunks = []chunk = file.read(1024)while chunk: chunks.append(chunk) chunk = file.read(1024)
# with walruschunks = []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
# withoutm = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag)if m: major, minor, patch = m.groups() do_something(major, minor, patch)
# withif 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:
- PEP 692
TypedDictfor**kwargs— looked clean, in practice the overhead of declaring aTypedDictfor a per-function kwargs schema was higher than just using explicit named arguments. StrEnum(3.11) — I forget which way to import it, and good oldclass Mode(str, Enum)works everywhere.- PEP 654
except*for non-concurrent code — feels overkill outside async / parallel contexts. Old-styleexceptis fine.
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.