Structural Pattern Matching — Advanced
Beyond simple literal matches, match statements can destructure classes, mappings, and sequences, filter with guards, and control exactly what gets captured.
Python 3.10 introduced structural pattern matching (PEP 634) via the match/case statement. Beyond matching simple literals, match can destructure the *shape* of a value — pulling fields out of objects, mapping entries out of dicts, and elements out of sequences — all while a pattern either matches or doesn't, and optionally binds names as it goes. This topic assumes familiarity with the basic match/case syntax and focuses on the advanced pattern kinds: class patterns, mapping patterns, sequence patterns, guards, and how capture and __match_args__ interact.
Recap: What match/case Does
A match statement evaluates a subject expression once, then tries each case pattern in order against it. The first pattern that matches runs its block; unlike switch in C-family languages, patterns aren't just equality checks — they can inspect a value's structure (type, length, fields) and bind parts of it to new names in one step.
def describe(value):
match value:
case 0:
return "zero"
case int() | float():
return "a number"
case _:
return "something else"
print(describe(0)) # zero
print(describe(3.14)) # a number
print(describe("hi")) # something elseSequence Patterns
A sequence pattern ([a, b, c] or (a, b, c)) matches any sequence (list, tuple, and other types registering as collections.abc.Sequence, but notably not str, bytes, or bytearray, which are excluded by design to avoid accidental character-by-character matching) whose length equals the pattern's element count. A starred element *rest matches any number of remaining items, just like unpacking assignment — see the Unpacking topic for the underlying mechanics.
Square brackets and parentheses are interchangeable for sequence patterns — [a, b] and (a, b) mean the same thing. Choose whichever reads more naturally for the data being matched.
def describe_point(coords):
match coords:
case []:
return "empty"
case [x]:
return f"1D: {x}"
case [x, y]:
return f"2D: ({x}, {y})"
case [x, y, z]:
return f"3D: ({x}, {y}, {z})"
case [x, *rest]:
return f"starts with {x}, then {len(rest)} more"
print(describe_point([1, 2])) # 2D: (1, 2)
print(describe_point([1, 2, 3, 4])) # starts with 1, then 3 moreMapping Patterns
A mapping pattern ({"key": value_pattern, ...}) matches any object implementing collections.abc.Mapping (most commonly dict) that contains at least the specified keys — extra keys in the actual mapping are ignored unless a **rest capture is included. Unlike sequence patterns, mapping patterns never require an exact match on size, only on the presence of the listed keys with matching values.
The {"type": "click", "x": x, "y": y} pattern requires those three keys to exist; x and y are capture patterns that bind whatever values are found there.
Keys in a mapping pattern must be literals or dotted constant references (see value patterns below) — you cannot use a bare capture name as a key.
def handle_event(event: dict):
match event:
case {"type": "click", "x": x, "y": y}:
return f"click at ({x}, {y})"
case {"type": "key", "key": key}:
return f"key press: {key}"
case {"type": "resize", **extra}:
return f"resize with extra data: {extra}"
case _:
return "unknown event"
print(handle_event({"type": "click", "x": 10, "y": 20}))
# click at (10, 20)
print(handle_event({"type": "resize", "width": 800, "height": 600}))
# resize with extra data: {'width': 800, 'height': 600}Class Patterns
A class pattern (ClassName(...)) matches an instance of that class (checked with isinstance, so subclasses match too) and can additionally destructure its attributes. There are two forms: keyword patterns (Point(x=0, y=0)), which match named attributes directly via getattr, and positional patterns (Point(0, 0)), which rely on the class defining __match_args__ to say which attributes the positional slots correspond to.
class Point:
__match_args__ = ("x", "y")
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
def classify(p):
match p:
case Point(x=0, y=0):
return "origin"
case Point(x=0, y=y):
return f"on y-axis at {y}"
case Point(x=x, y=0):
return f"on x-axis at {x}"
case Point(x, y): # positional -- uses __match_args__
return f"at ({x}, {y})"
case _:
return "not a point"
print(classify(Point(0, 0))) # origin
print(classify(Point(0, 5))) # on y-axis at 5
print(classify(Point(3, 4))) # at (3, 4)__match_args__ in Depth
__match_args__ is a class attribute — a tuple of attribute names in order — that tells match which positional slot in a class pattern corresponds to which attribute. Point(3, 4) as a pattern is exactly equivalent to Point(x=3, y=4) given __match_args__ = ("x", "y"). Without __match_args__, only keyword patterns work for that class; a positional class pattern against a class with no (or a too-short) __match_args__ raises TypeError at match time.
dataclasses.dataclass generates __match_args__ automatically from the declared fields (in declaration order), which is one reason dataclasses and pattern matching pair so naturally — see the related Dataclasses topic.
@dataclass auto-generates __match_args__ = ('x', 'y', 'z'), so the positional class pattern works without any extra code.
from dataclasses import dataclass
@dataclass
class Vector:
x: float
y: float
z: float = 0.0
match Vector(1.0, 2.0):
case Vector(x, y, z):
print(x, y, z) # 1.0 2.0 0.0Guards: Adding Conditions to a Pattern
A guard is an if clause after a pattern (case pattern if condition:) that adds an arbitrary boolean condition on top of the structural match. The pattern must match first — only then is the guard expression evaluated, with any names the pattern captured already bound and available to it. If the guard is false, matching moves on to the next case as if the pattern itself hadn't matched.
A guard can reference any name bound earlier in the same pattern, and even names from outer scope — but it cannot itself bind new names; guards are ordinary expressions, not patterns.
def classify_number(n):
match n:
case int() as i if i < 0:
return "negative int"
case int() as i if i == 0:
return "zero"
case int():
return "positive int"
case float():
return "a float"
print(classify_number(-5)) # negative int
print(classify_number(7)) # positive intCapture Semantics: Bare Names, Wildcards, and Value Patterns
A bare lowercase name in a pattern (x, point) is always a capture pattern — it matches anything and binds that name, unconditionally. This is different from an equality check: case x: never fails to match. To match against an existing variable's *value* rather than capture a new name, use a dotted name (a value pattern), such as case Color.RED: or case some_module.EXPECTED: — any name with a dot is treated as a value to compare against, not a name to bind. The wildcard _ matches anything and binds nothing, conventionally used as a catch-all final case.
This is a common trap: case Status.OK: matches by value because of the dot, but case ok: (no dot) would ALWAYS match and rebind ok, silently shadowing an outer variable of the same name rather than comparing against it.
import enum
class Status(enum.Enum):
OK = "ok"
ERROR = "error"
def handle(status):
match status:
case Status.OK: # value pattern -- compares to Status.OK
return "all good"
case Status.ERROR:
return "failed"
case other: # bare name -- ALWAYS matches, captures as 'other'
return f"unhandled: {other}"
print(handle(Status.OK)) # all good
print(handle("weird")) # unhandled: weirdOR Patterns and Binding with as
The | operator combines several patterns into one: the case matches if any alternative matches. All alternatives in an OR pattern must bind the same set of names, so the chosen branch's captures are always available afterward. The as keyword binds the whole matched value (or sub-pattern) to a name in addition to whatever the pattern itself captures — useful for keeping a reference to the full object while also destructuring pieces of it.
def describe(value):
match value:
case int() | float() as number:
return f"numeric: {number}"
case [1 | 2 | 3, *_]:
return "starts with a small number"
case _:
return "other"
print(describe(4.5)) # numeric: 4.5
print(describe([2, 9, 9])) # starts with a small numberNested Patterns
Patterns compose: a sequence pattern can contain class patterns, which can contain mapping patterns, and so on, letting one case destructure deeply nested data in a single readable line — something that would otherwise take several isinstance/index/key checks.
def handle(command):
match command:
case {"action": "move", "target": [x, y]}:
return f"move to ({x}, {y})"
case {"action": "batch", "steps": [{"action": str(a)}, *more]}:
return f"batch starting with {a}, {len(more)} more steps"
case _:
return "unrecognized"
print(handle({"action": "move", "target": [3, 4]}))
# move to (3, 4)Edge Cases
A case block with no guard and a wildcard or capture pattern always matches, so it must come last — any case after an unconditional match is unreachable (some static checkers flag this). Class patterns check isinstance, so a bool will match case int(): too since bool subclasses int — use an explicit guard (case int() as i if not isinstance(i, bool):) if that distinction matters. Sequence patterns explicitly exclude str/bytes/bytearray from matching [a, b]-style patterns specifically to prevent a string from being silently treated as a sequence of one-character elements.
match True:
case int():
print("bool matched int()!") # this runs -- bool is a subclass of intPractical Usage
Advanced pattern matching shines for: parsing structured messages (JSON-like dicts from an API, AST-like nodes), interpreters and command dispatchers where the shape of input determines behavior, and replacing chains of isinstance/if/elif checks on data classes with one declarative match block. It's less suited to matching against a large number of arbitrary boolean conditions unrelated to the subject's structure — plain if/elif is often clearer there.
Best Practices
Define __match_args__ (or use @dataclass) on classes you intend to pattern-match positionally, to keep call sites concise. Prefer keyword class patterns for public APIs where field order might change, since keyword patterns are order-independent and self-documenting. Keep guards simple and side-effect-free — a guard is re-evaluated only if its pattern matches, but complex guard logic is easier to read as a helper function called from the guard. Always end a match with a final wildcard case _: when the subject's possible shapes aren't exhaustively enumerable, to avoid silently falling through with no matching case.
Key Takeaways
Sequence patterns match list/tuple-like objects by length and optional starred elements; they deliberately exclude str/bytes/bytearray.
Mapping patterns match by required-key presence, not exact size, and can capture leftover keys with **rest.
Class patterns use isinstance for the type check; positional class patterns rely on the class's __match_args__ tuple to map slots to attributes.
@dataclass auto-generates __match_args__ from field declaration order, making dataclasses a natural fit for pattern matching.
A guard (if condition) runs only after its pattern matches and can use names the pattern already bound.
A bare lowercase name always captures and matches anything; a dotted name is a value pattern compared by equality — mixing these up is a common bug.
Patterns compose: sequence, mapping, and class patterns can nest arbitrarily to destructure complex structures in one case.
OK = "ok" match status: case OK: # bare name -- ALWAYS matches, rebinds OK, never compares! print("matched")
OK = "ok" match status: case s if s == OK: # or wrap OK in an enum / use a dotted value pattern print("matched")
A bare name in a pattern always captures rather than compares. To match against an existing constant's value, use a dotted value pattern (like Enum.MEMBER) or a guard — never a bare name.
class Point: def __init__(self, x, y): self.x, self.y = x, y match p: case Point(x, y): # TypeError: no __match_args__ defined print(x, y) class Point: __match_args__ = ("x", "y") def __init__(self, x, y): self.x, self.y = x, y match p: case Point(x, y): print(x, y)
Positional class patterns require __match_args__; without it, only keyword patterns (Point(x=.., y=..)) work.
match value: case _: print("default") case int(): # unreachable -- wildcard already matched everything above print("an int") match value: case int(): print("an int") case _: print("default") # wildcard/catch-all goes last
A case with an unconditional capture or wildcard pattern always matches, so any case after it can never be reached; order specific patterns before the catch-all.
Why does case [x, y]: never match a string like "ab", even though a string has two characters?
Sequence patterns explicitly exclude str, bytes, and bytearray from matching, precisely to prevent strings from being treated as sequences of individual characters by accident.
What is the difference in behavior between case Point(x, y): and case Point(x=x, y=y):?
They're equivalent in effect but rely on different mechanisms: the positional form needs Point.__match_args__ to map slot 0 and slot 1 to attribute names x and y; the keyword form matches those attributes directly by name and works even without __match_args__.
In case int() as i if i < 0:, when is the guard i < 0 evaluated relative to the pattern match?
Only after int() has already matched and i has been bound by the as clause — guards run after a successful structural match, using any names the pattern captured.
Why does bool match case int():, and how would you write a case that matches int but excludes bool?
bool is a subclass of int in Python, and class patterns use isinstance semantics, so any bool value satisfies int(). To exclude bools, add a guard: case int() as i if not isinstance(i, bool):.
What names does case {"type": "resize", **extra}: bind when matched against {"type": "resize", "w": 10, "h": 20}?
It binds extra = {'w': 10, 'h': 20} — extra in a mapping pattern captures every key not explicitly named in the pattern (here, everything except "type"), similar to kwargs in a function signature.
Can two alternatives joined by | in an OR pattern bind different sets of names? Why or why not?
No — every alternative in an OR pattern must bind the exact same set of names, so that whichever branch actually matches leaves a consistent, predictable set of names available afterward.
How does @dataclass make positional class patterns work automatically?
@dataclass generates __match_args__ as a tuple of the class's field names in declaration order, so a positional pattern like Vector(x, y, z) automatically lines up with the dataclass's fields without any manual __match_args__ definition.
Write a function parse_command(cmd: dict) that pattern-matches a dict with a "type" key of "move" (with "dx" and "dy" keys) or "quit", returning a descriptive string, and "unknown command" otherwise.
def parse_command(cmd: dict) -> str: match cmd: case {"type": "move", "dx": dx, "dy": dy}: return f"move by ({dx}, {dy})" case {"type": "quit"}: return "quitting" case _: return "unknown command" print(parse_command({"type": "move", "dx": 1, "dy": -1})) # move by (1, -1) print(parse_command({"type": "quit"})) # quitting print(parse_command({"type": "jump"})) # unknown command
Mapping patterns match dicts that contain at least the given keys; two cases handle the two known shapes, and a final case _: catches anything else.
What does this print?
big circle — the first case matches (kind is 'circle' and size=10 satisfies the guard size > 5), so it runs and the later cases are never checked.
Write a function sum_leading_evens(items) that uses a sequence pattern with a guard to return the first element if the list starts with an even number, or 0 otherwise. Use case [x, *_] if x % 2 == 0:.
def sum_leading_evens(items: list) -> int: match items: case [x, *_] if x % 2 == 0: return x case _: return 0 print(sum_leading_evens([4, 1, 3])) # 4 print(sum_leading_evens([3, 4, 5])) # 0 print(sum_leading_evens([])) # 0
[x, *_] requires at least one element (binding the first to x and discarding the rest), and the guard checks it's even; an empty list fails to match [x, *_] at all (needs at least one item), so it falls through to case _:.
class Shape:
__match_args__ = ("kind", "size")
def __init__(self, kind, size):
self.kind = kind
self.size = size
s = Shape("circle", 10)
match s:
case Shape(kind="circle", size=size) if size > 5:
result = "big circle"
case Shape(kind="circle"):
result = "small circle"
case Shape():
result = "other shape"
print(result)