Structural Pattern Matching in Python — Deep Dive
Technical perspective
Structural pattern matching (PEPs 634–636) is the most significant syntax addition to Python since async/await. Under the surface, it compiles to a decision tree of type checks, length checks, and attribute accesses — no hash tables, no __eq__ calls for class patterns. Understanding this compilation model helps you write patterns that are both readable and efficient.
The compilation model
CPython compiles match statements into a sequence of bytecode operations that form a decision tree. Each pattern compiles to:
- Type check —
MATCH_CLASSorisinstanceequivalent - Length check — for sequences, verify element count
- Element/attribute access — extract values for sub-patterns
- Guard evaluation — if a guard exists, evaluate it as a final filter
The compiler optimises common cases. A match with only literal patterns compiles similarly to a dictionary lookup in some implementations, though CPython 3.10–3.12 uses sequential checking.
The __match_args__ protocol
Class patterns like Point(1, 2) use positional matching, which requires the class to define __match_args__:
class Point:
__match_args__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
match point:
case Point(0, 0):
print("Origin")
case Point(x, 0):
print(f"On x-axis at {x}")
case Point(0, y):
print(f"On y-axis at {y}")
case Point(x, y):
print(f"At ({x}, {y})")
Dataclasses automatically generate __match_args__ from their field definitions, making them natural companions for pattern matching:
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
Shape = Circle | Rectangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
Nested destructuring
Patterns compose. You can nest sequence patterns inside mapping patterns inside class patterns:
match event:
case {"type": "batch", "items": [first, second, *rest]}:
print(f"Batch with {2 + len(rest)} items, first: {first}")
case {"type": "error", "details": {"code": code, "message": msg}}:
print(f"Error {code}: {msg}")
This eliminates chains of key lookups and index accesses that would otherwise require defensive get() calls and length checks.
Building a command dispatcher
A practical pattern for CLI tools or chatbots:
def dispatch(tokens: list[str]) -> str:
match tokens:
case ["help"]:
return show_help()
case ["get", resource]:
return fetch_resource(resource)
case ["get", resource, "--format", fmt]:
return fetch_resource(resource, format=fmt)
case ["set", resource, value]:
return update_resource(resource, value)
case ["delete", resource] if confirm_deletion(resource):
return delete_resource(resource)
case ["delete", _]:
return "Deletion cancelled"
case [command, *_]:
return f"Unknown command: {command}"
case []:
return "No command provided"
Each case handles a different command shape. Guards add runtime conditions without nesting if statements. The *_ captures and discards extra arguments.
AST walking with pattern matching
Pattern matching transforms AST processing from verbose visitor patterns into concise, readable dispatch:
import ast
def count_functions(node: ast.AST) -> int:
match node:
case ast.Module(body=statements):
return sum(count_functions(s) for s in statements)
case ast.FunctionDef() | ast.AsyncFunctionDef():
return 1 + sum(
count_functions(child) for child in ast.iter_child_nodes(node)
)
case ast.ClassDef(body=statements):
return sum(count_functions(s) for s in statements)
case _:
return sum(
count_functions(child) for child in ast.iter_child_nodes(node)
)
Compare this to the traditional ast.NodeVisitor approach — the match version makes the structural expectations explicit in each case.
Protocol message parsing
Pattern matching excels when handling messages from external systems with varying schemas:
def handle_webhook(payload: dict) -> None:
match payload:
case {
"event": "payment.completed",
"data": {"amount": int(amount), "currency": str(currency)},
}:
record_payment(amount, currency)
case {
"event": "payment.failed",
"data": {"error_code": code},
} if code in RETRYABLE_CODES:
schedule_retry(payload)
case {
"event": "payment.failed",
"data": {"error_code": code},
}:
alert_team(f"Non-retryable payment failure: {code}")
case {"event": event_type}:
log_unhandled_event(event_type)
The nested type checks (int(amount), str(currency)) validate types and extract values simultaneously — something that would require multiple isinstance calls and dictionary lookups without pattern matching.
Performance characteristics
Pattern matching performance depends on the pattern type:
| Pattern type | Approximate cost | Notes |
|---|---|---|
| Literal | O(n) cases checked | Sequential comparison in CPython |
| Capture/wildcard | O(1) | Always matches |
| Sequence | O(k) per case | k = number of elements in pattern |
| Mapping | O(k) per case | k = number of keys checked |
| Class | O(1) isinstance + O(k) attrs | k = number of attributes checked |
| OR | Sum of component costs | Each alternative checked |
For hot paths with many literal cases (>20), a dictionary dispatch may be faster. For structural patterns, the readability benefit usually outweighs minor performance differences.
# Dictionary dispatch for pure literal matching
HANDLERS = {
"click": handle_click,
"keypress": handle_keypress,
"scroll": handle_scroll,
}
handler = HANDLERS.get(event_type, handle_unknown)
handler(event)
Edge cases and gotchas
Name binding vs constant matching
STATUS_OK = 200
match response.status:
case STATUS_OK: # BUG: This captures into STATUS_OK, doesn't compare!
pass
To match constants, use dotted names or enums:
from enum import Enum
class Status(Enum):
OK = 200
NOT_FOUND = 404
match response.status:
case Status.OK: # Correct: dotted name is a value pattern
pass
Sequence matching gotcha with strings
Strings are sequences in Python, so case [first, *rest]: matches a string character by character. If you want to match strings as atoms, put the string literal pattern before any sequence pattern:
match value:
case str(): # Match any string as a whole
handle_string(value)
case [x, y]: # Match a two-element list
handle_pair(x, y)
Guards can access captured variables
match point:
case (x, y) if x == y:
print(f"On diagonal at {x}")
The variables x and y are bound by the pattern and available in the guard. This enables powerful filtering without separate if statements.
Real-world adoption
The Python standard library itself uses pattern matching in ast.py and tomllib. Major frameworks are adopting it:
- FastAPI uses it internally for request routing decisions
- Textual (terminal UI framework) uses it for event handling
- Polars uses match statements in its Python expression engine
The pattern is also common in data engineering pipelines where incoming records have varying schemas — Airflow DAGs, Kafka consumers, and ETL processors benefit from explicit structural dispatch.
Comparison with alternatives
| Approach | Readability | Type narrowing | Destructuring | Performance |
|---|---|---|---|---|
| if/elif chain | Low for >3 cases | Manual isinstance | Manual indexing | Good |
| Dictionary dispatch | Medium | None | None | Best for literals |
| match/case | High | Automatic | Built-in | Good |
| Visitor pattern | Medium | Via method dispatch | Manual | Good |
The one thing to remember: Structural pattern matching combines type checking, destructuring, and conditional logic into a single readable construct — use it whenever your code branches on the shape of data rather than simple equality.
See Also
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- 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.