Python 3.10 New Features — Deep Dive
Technical overview
Python 3.10 (October 2021) shipped 10 PEPs. The headline — structural pattern matching — introduced new syntax for the first time since async/await in 3.5. But several smaller changes had outsized practical impact. This deep dive covers internals, edge cases, and migration paths.
Pattern matching internals
Compilation to bytecode
CPython compiles match statements into decision trees. Each pattern generates a sequence of:
- Type checks via
MATCH_CLASSor equivalentisinstancecalls - Length checks for sequence patterns
- Key lookups for mapping patterns (
MATCH_KEYSopcode) - Attribute access for class patterns
- Guard evaluation as a final boolean filter
import dis
def classify(point):
match point:
case (0, 0):
return "origin"
case (x, 0):
return f"x-axis at {x}"
case (0, y):
return f"y-axis at {y}"
case (x, y):
return f"point({x}, {y})"
dis.dis(classify)
Running dis.dis reveals MATCH_SEQUENCE, GET_LEN, MATCH_MAPPING, and related opcodes. The compiler doesn’t create a jump table — it evaluates patterns sequentially, so ordering matters for performance.
The __match_args__ protocol
Class patterns like Point(1, 2) require the class to declare positional matching order:
class Point:
__match_args__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
match Point(1, 2):
case Point(x=1, y=y):
print(f"y is {y}")
@dataclass and NamedTuple auto-generate __match_args__. For hand-rolled classes, you must add it manually.
Irrefutable patterns and exhaustiveness
Python doesn’t enforce exhaustive matching (unlike Rust). A match without a wildcard _ case silently falls through. Static type checkers like pyright can warn about missing cases if you match on Enum or Literal types.
Soft keywords
match and case are soft keywords — they’re only special inside match blocks. You can still have variables named match or case. This was a deliberate choice to avoid breaking existing code.
Enhanced error messages — implementation
PEP 657: Fine-grained error locations
The compiler now stores column offsets and end positions for every AST node. When an exception occurs at runtime, the traceback formatter uses these offsets to draw a caret under the precise sub-expression:
Traceback (most recent call last):
File "test.py", line 3, in <module>
result = x / (y + z)
~~^~~
ZeroDivisionError: division by zero
The .pyc format grew to store co_endcoloffset and co_endlineno per instruction. This adds roughly 10-15% to .pyc file size. You can disable it with PYTHONNODEBUGRANGES=1 or -X no_debug_ranges for production builds where file size matters.
SyntaxError improvements
The parser was rewritten to PEG in 3.9, and 3.10 leverages PEG’s richer error recovery to produce contextual messages:
# Missing colon
if True
print("hello")
# Python 3.10: "expected ':'" pointing at end of 'if True'
# Unclosed bracket
data = [1, 2, 3
print(data)
# Python 3.10: "did you forget a ']'?" pointing at line 1's '['
Union type syntax — mechanics
int | str creates a types.UnionType object at runtime:
>>> type(int | str)
<class 'types.UnionType'>
>>> int | str == typing.Union[int, str]
True
This works in:
- Function annotations:
def f(x: int | str) -> None isinstance/issubclass:isinstance(42, int | str)- Variable annotations:
x: int | str = "hello" - As
TypeAliastargets
It does not work in subscript expressions on older Python, so conditional imports or from __future__ import annotations may be needed in libraries supporting 3.9.
ParamSpec deep dive (PEP 612)
ParamSpec captures the entire parameter signature (positional + keyword) as a single variable. It solves the long-standing “decorator erases signature” problem:
from typing import ParamSpec, TypeVar, Callable
import functools
P = ParamSpec("P")
R = TypeVar("R")
def retry(max_attempts: int = 3):
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_attempts - 1:
raise
return wrapper
return decorator
P.args and P.kwargs are special attributes that only work inside function signatures. They tell the type checker that wrapper accepts exactly the same arguments as func.
Concatenation with Concatenate
You can prepend parameters:
from typing import Concatenate
def with_context(func: Callable[Concatenate[Context, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
ctx = get_current_context()
return func(ctx, *args, **kwargs)
return wrapper
TypeGuard (PEP 647)
Narrows types in conditional branches:
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process(items: list[object]):
if is_string_list(items):
# Type checker knows items is list[str] here
print(items[0].upper())
This replaced hacky cast() calls and # type: ignore comments in many codebases.
Migration checklist: upgrading to 3.10
- Run tests on 3.10 — most code works unchanged, but check for:
- Libraries using
collections.Callable(removed; usecollections.abc.Callable) distutilsdeprecation warnings (removed fully in 3.12)
- Libraries using
- Adopt
X | Ysyntax — find-and-replaceUnion[X, Y]in type hints - Replace
if/elifchains withmatch/casewhere destructuring adds clarity - Add
__match_args__to custom classes you want to match on - Review
.pycsizes in constrained environments; disable debug ranges if needed - Upgrade
mypy/pyrightto versions supporting 3.10 syntax
Performance notes
- Pattern matching is sequential; for hot paths with many literal cases, a dictionary dispatch may still be faster
- The new PEG parser is slightly slower at parsing than the old LL(1) parser, but the difference is negligible (microseconds per module load)
.pycfiles are ~12% larger due to column offset storage
What libraries adopted first
- FastAPI used
ParamSpecto improve decorator typing in v0.70+ - Pydantic v2 uses pattern matching internally for schema construction
- Django 4.0 set 3.10 as the minimum testing target and later adopted
X | Ytype hints
The one thing to remember: Python 3.10 wasn’t just new syntax — it rewired how Python reports errors and types, making the developer experience measurably better across the board.
See Also
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
- Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
- Python Exception Groups Python's ExceptionGroup is like getting one report card that lists every mistake at once instead of stopping at the first one.
- Python Free Threading Nogil Python has always had a rule that only one thing can happen at a time — free threading finally changes that, like opening extra checkout lanes at the grocery store.