Structural Pattern Matching
Introduced in Python 3.10, match/case matches the *shape* of a value — literals, sequences, mappings, and object attributes — going well beyond a simple value-equality switch statement.
match/case (Python 3.10+) is Python's structural pattern matching statement. Despite superficial resemblance to switch statements in other languages, it does far more: it can destructure sequences and mappings, match against class instance attributes, bind variables directly from the matched structure, and combine patterns with guards — all while comparing the *shape* of data, not just a single scalar value.
Basic Syntax: match and case
A match statement takes a subject expression, and each case clause specifies a pattern to test the subject against, top to bottom, running the first matching clause's block and then stopping (no fallthrough to later cases).
def describe_status(code: int) -> str:
match code:
case 200:
return 'OK'
case 404:
return 'Not Found'
case 500:
return 'Server Error'
case _:
return 'Unknown status'
print(describe_status(404)) # 'Not Found'
print(describe_status(999)) # 'Unknown status'Literal Patterns and the Wildcard
The simplest patterns are literals — numbers, strings, True/False/None — matched by equality-like comparison. The wildcard pattern _ matches anything and binds nothing; it's conventionally used as the final 'catch-all' case, playing a role similar to default in other languages' switch statements. Unlike an ordinary variable name, _ is never bound to a value and is always safe to reuse without shadowing anything meaningful.
A match statement is NOT required to be exhaustive by the interpreter — if no case matches and there's no wildcard, execution simply falls through the entire match block silently, doing nothing. This differs from languages that enforce exhaustiveness checking for enums or algebraic data types.
def handle(value):
match value:
case None:
print('nothing here')
case True:
print('affirmative')
case 'quit':
print('exiting')
case _:
print('unrecognized')
handle(None) # nothing here
handle('quit') # exiting
handle(42) # unrecognizedOR Patterns
The | operator inside a pattern combines multiple alternative patterns into one case, matching if any of them match — analogous to multiple values falling through to the same case in a C-style switch.
def classify_char(c: str) -> str:
match c:
case 'a' | 'e' | 'i' | 'o' | 'u':
return 'vowel'
case c if c.isalpha():
return 'consonant'
case _:
return 'not a letter'
print(classify_char('e')) # 'vowel'
print(classify_char('z')) # 'consonant'
print(classify_char('7')) # 'not a letter'Variable Binding: Capture Patterns
A bare name in a pattern (other than _) is a capture pattern: it always matches, and binds that name to the matched value, just like an assignment. This is different from a literal pattern — writing an existing variable's name doesn't compare against its current value; it rebinds it. To match against a variable's *value*, use a dotted name (an attribute or enum member) or a guard.
case other: above matches *anything* and binds it to other -- functionally similar to case _: except it also gives you a name to use in the block. _ is preferred specifically when you don't need the value at all.
def show(value):
match value:
case int() as n:
print(f'an integer: {n}')
case str() as s:
print(f'a string: {s!r}')
case other:
print(f'something else: {other!r}')
show(42) # an integer: 42
show('hi') # a string: 'hi'
show(3.14) # something else: 3.14Sequence Patterns
A pattern written as [a, b, c] or (a, b, c) matches a sequence (list, tuple, and similar) of that exact length, binding each element to the corresponding name. A *rest element (like unpacking) captures any number of remaining items into a list.
def describe_point(point):
match point:
case (0, 0):
return 'origin'
case (x, 0):
return f'on the x-axis at {x}'
case (0, y):
return f'on the y-axis at {y}'
case (x, y):
return f'point at ({x}, {y})'
case _:
return 'not a 2D point'
print(describe_point((0, 0))) # 'origin'
print(describe_point((3, 0))) # 'on the x-axis at 3'
print(describe_point((2, 5))) # 'point at (2, 5)'def describe_list(items):
match items:
case []:
return 'empty'
case [only]:
return f'one item: {only}'
case [first, *rest]:
return f'starts with {first}, {len(rest)} more after it'
print(describe_list([])) # 'empty'
print(describe_list([5])) # 'one item: 5'
print(describe_list([1, 2, 3])) # 'starts with 1, 2 more after it'Mapping Patterns
A pattern like {'key': value} matches a mapping (dict-like) that has at least that key (extra keys are ignored, unlike sequence patterns which require an exact length unless *rest is used). A **rest element captures any remaining key-value pairs into a new dict.
def handle_event(event: dict):
match event:
case {'type': 'click', 'x': x, 'y': y}:
print(f'Click at ({x}, {y})')
case {'type': 'keypress', 'key': key}:
print(f'Key pressed: {key}')
case {'type': event_type}:
print(f'Unhandled event: {event_type}')
case _:
print('Malformed event')
handle_event({'type': 'click', 'x': 10, 'y': 20}) # Click at (10, 20)
handle_event({'type': 'scroll', 'delta': 5}) # Unhandled event: scrollClass Patterns
A pattern like ClassName(attr1=pattern1, attr2=pattern2) matches instances of ClassName (or a subclass) whose attributes match the given sub-patterns — this checks both type and structure at once. Positional sub-patterns are also supported for classes that define __match_args__ (dataclasses get this automatically).
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def describe(shape):
match shape:
case Point(x=0, y=0):
return 'origin point'
case Point(x=0, y=y):
return f'on y-axis at {y}'
case Point(x=x, y=y):
return f'point at ({x}, {y})'
case _:
return 'not a point'
print(describe(Point(0, 0))) # origin point
print(describe(Point(3, 4))) # point at (3, 4)
# Positional form works because dataclasses auto-generate __match_args__
def describe_positional(shape):
match shape:
case Point(0, 0):
return 'origin point'
case Point(x, y):
return f'point at ({x}, {y})'
print(describe_positional(Point(3, 4))) # point at (3, 4)Guards: Adding Conditions to a Pattern
A guard is an if <condition> appended to a case clause. The pattern must match *and* the guard must evaluate truthy for that case to be selected; if the guard is false, matching continues to the next case as though this one hadn't matched at all — variables bound by the pattern are still available inside the guard expression.
def classify(n):
match n:
case int(x) if x < 0:
return 'negative integer'
case int(x) if x == 0:
return 'zero'
case int(x):
return 'positive integer'
case _:
return 'not an integer'
print(classify(-5)) # negative integer
print(classify(0)) # zero
print(classify(7)) # positive integerNested and Combined Patterns
Patterns compose: sequence patterns can contain mapping patterns, class patterns can contain sequence patterns, and so on — matching arbitrarily nested structure in a single case clause, which is exactly the kind of destructuring that would otherwise require multiple nested if/isinstance checks.
def handle_response(response):
match response:
case {'status': 200, 'data': {'items': [first, *_rest]}}:
print(f'First item: {first}')
case {'status': 200, 'data': {'items': []}}:
print('No items returned')
case {'status': status} if status >= 400:
print(f'Error response: {status}')
case _:
print('Unrecognized response shape')
handle_response({'status': 200, 'data': {'items': ['a', 'b']}}) # First item: a
handle_response({'status': 404, 'data': {}}) # Error response: 404Exhaustiveness Is Not Enforced
Unlike languages such as Rust or Swift, where the compiler forces a match to cover every possible case of an enum-like type, Python performs no static exhaustiveness checking whatsoever — match/case is checked at runtime, one pattern at a time, and if nothing matches, the statement simply does nothing (no error, no warning). If a case must always be handled, always include an explicit case _: to make the fallback intentional rather than accidental.
This silent fallthrough is a common source of subtle bugs. Static type checkers (a third-party/ecosystem tool like mypy) can sometimes flag non-exhaustive matches over Enum or Literal types with extra effort, but the Python runtime itself never enforces it.
def handle_direction(d):
match d:
case 'north':
print('going up')
case 'south':
print('going down')
# no case for 'east'/'west', and no wildcard -- silently does nothing
handle_direction('east') # prints nothing at all, no error raisedmatch vs. if/elif Chains and Dictionary Dispatch
match/case is most valuable when a value's *shape* matters — destructuring sequences, mappings, or class attributes in the same statement that decides the branch. For simple scalar-equality dispatch (a handful of exact values mapping to actions), a plain elif chain or dictionary lookup is often just as clear and doesn't require Python 3.10+. Reach for match specifically when you'd otherwise need multiple nested isinstance/len/key-existence checks to express the same logic.
Key Takeaways
match/case (Python 3.10+) matches structure — literals, sequences, mappings, and class attributes — not just a single scalar value like a classic switch statement.
_ is the wildcard: matches anything, binds nothing, and is the conventional catch-all case.
A bare name in a pattern is a capture (binds the value); use dotted names, literals, or guards to compare against an existing value instead of rebinding it.
Sequence patterns require an exact length unless *rest is used; mapping patterns match on a subset of keys by default.
Class patterns check both type and attribute structure together; positional sub-patterns need __match_args__ (dataclasses provide this automatically).
Guards (case pattern if condition:) add extra runtime conditions beyond what the pattern shape alone can express.
Python enforces NO exhaustiveness checking on match — an unmatched value silently falls through doing nothing, so include case _: whenever every case must be handled.
expected = 'quit' match command: case expected: # WRONG: this is a capture pattern, always matches, rebinds 'expected' print('matched') expected = 'quit' match command: case str() as cmd if cmd == expected: print('matched') # or, simpler for a known constant, use a literal directly: match command: case 'quit': print('matched')
A bare name in a case pattern is always a capture pattern — it matches anything and binds the name, it does NOT compare against the name's existing value. To compare against a variable, use a guard or a dotted/attribute reference.
match value: case 'north': go_up() case 'south': go_down() # no wildcard -- silently does nothing for any other input, including typos match value: case 'north': go_up() case 'south': go_down() case _: raise ValueError(f'Unknown direction: {value!r}')
Python performs no exhaustiveness checking on match statements. Omitting a wildcard case means unexpected inputs are silently ignored rather than flagged — usually not what you want for anything beyond a deliberately partial match.
match point: case (x, y, z): # assumes 3 elements are always safe to match first print('3D point') case (x, y): print('2D point') match point: case (x, y): print('2D point') case (x, y, z): print('3D point')
Sequence patterns require an EXACT length match. Ordering matters here only insofar as more specific/likely cases should come first for readability, but more importantly: a 2-tuple will never match the 3-element pattern, so as written this particular example works — the real risk is assuming an exact-length pattern will match sequences of other lengths without testing it.
How does match/case differ fundamentally from a classic switch statement found in other languages?
It matches on the structure (shape) of the value, not just scalar equality — patterns can destructure sequences, mappings, and object attributes, binding variables from the matched pieces, all in a single case clause.
What does the wildcard pattern _ do, and why is it safe to use even if _ is already used elsewhere in the code?
_ matches any value but binds nothing at all -- it's a special no-op pattern, not a real variable assignment, so using it never shadows or overwrites any existing name.
Why does case some_existing_variable: not compare against that variable's current value?
Any bare name in a case pattern (other than _) is treated as a capture pattern, which always matches and binds the name to the matched value -- it is not a value comparison. To compare against an existing variable, use a guard (case x if x == some_existing_variable:) instead.
What is the difference between how sequence patterns and mapping patterns handle extra elements/keys?
A sequence pattern like [a, b] requires an EXACT length match (unless *rest is used to capture extras). A mapping pattern like {'key': value} only requires the specified keys to be present -- any additional keys in the dict are ignored automatically.
Does Python enforce that a match statement covers every possible case? What happens if none of the cases match?
No, Python performs no static or runtime exhaustiveness checking. If no case (including no wildcard) matches the subject, the entire match statement does nothing and execution simply continues after it -- no error is raised.
What is required for a class to support positional patterns like case Point(x, y): rather than only keyword patterns like case Point(x=x, y=y):?
The class must define __match_args__, a tuple naming which attributes correspond to which positional slots. Dataclasses generate this automatically based on field order, which is why @dataclass-decorated classes support positional matching out of the box.
When would an ordinary if/elif chain be preferable to match/case for the same logic?
When the dispatch is based on simple scalar equality against a handful of exact values with no destructuring involved -- an elif chain (or a dictionary lookup) is just as clear, doesn't require Python 3.10+, and doesn't introduce pattern-matching machinery for a case that doesn't need it.
What does each of these three calls print?
[3, 4], then [], then 'no match'. [1, 2, *rest] requires the sequence to start with exactly 1 then 2; *rest captures everything after as a list (empty if nothing remains). The third call starts with 9, not 1, so it falls through to the wildcard.
Write a function parse_command(cmd) using match/case that takes a string, splits it on spaces, and matches: ['quit'] -> 'Exiting', ['move', direction] -> f'Moving {direction}', ['move', direction, *rest] with len(rest) > 0 as a guard -> 'Too many arguments', anything else -> 'Unknown command'.
def parse_command(cmd: str) -> str: match cmd.split(): case ['quit']: return 'Exiting' case ['move', direction, *rest] if rest: return 'Too many arguments' case ['move', direction]: return f'Moving {direction}' case _: return 'Unknown command' print(parse_command('quit')) # Exiting print(parse_command('move north')) # Moving north print(parse_command('move north fast')) # Too many arguments print(parse_command('jump')) # Unknown command
Splitting the string turns it into a list, which sequence patterns can then match on length and content. The guarded *rest case is checked before the plain 2-element case so that extra arguments are caught first (order matters -- case clauses are tried top to bottom).
What does this print?
12.56636, then 12. The class pattern Circle(radius=r) matches any Circle instance and binds its radius attribute to r; similarly for Rectangle. Each case computes the shape-specific area formula.
Write a function describe_event(event) that matches a dict-shaped event with keys 'type' and optionally 'payload', printing a shape-specific message for type 'login' (expects a 'user' key in payload), type 'logout', and a fallback for anything else -- using nested mapping patterns.
def describe_event(event: dict) -> str: match event: case {'type': 'login', 'payload': {'user': user}}: return f'{user} logged in' case {'type': 'logout'}: return 'A user logged out' case {'type': event_type}: return f'Unhandled event type: {event_type}' case _: return 'Malformed event' print(describe_event({'type': 'login', 'payload': {'user': 'ann'}})) # ann logged in print(describe_event({'type': 'logout'})) # A user logged out print(describe_event({'type': 'ping'})) # Unhandled event type: ping
The first case pattern nests a mapping pattern inside another mapping pattern, matching only events whose 'payload' itself contains a 'user' key -- capturing it directly without a separate lookup. Mapping patterns ignore extra unmatched keys by default, so this works even if 'payload' has other fields too.
def check(value):
match value:
case [1, 2, *rest]:
return rest
case _:
return 'no match'
print(check([1, 2, 3, 4]))
print(check([1, 2]))
print(check([9, 2, 3]))from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
def area(shape):
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case _:
return None
print(area(Circle(2)))
print(area(Rectangle(3, 4)))