PYTHON / CONTROL FLOW
match statements and structural pattern matching
Use match/case to destructure sequences, mappings and objects in one step, with guards, or-patterns and wildcards instead of long comparison chains.
What you will learn
- Destructure lists, tuples, dicts and objects directly inside case patterns
- Combine alternatives with | and narrow a branch further with an if guard
- Know that a bare name captures instead of comparing; use a dotted name for constants
- Handle the no-match case yourself, since match never raises when nothing fits
Understanding match statements and structural pattern matching
A match statement (Python 3.10 and later) evaluates one subject expression and then tries each case in written order until a pattern fits. The important difference from an elif chain is what a case contains: not a boolean expression, but a pattern describing the shape of the data. `case [x, y]` asks "is this a sequence of exactly two items?" and, if so, binds those items to x and y in a single step. That combination of test-and-unpack is the whole reason the feature exists.
Patterns compose recursively, so `case {'type': 'click', 'pos': (x, y)}` checks that the subject is a mapping, that it has a 'type' key equal to 'click', that it has a 'pos' key whose value is a two-element sequence, and only then binds x and y. Mapping patterns match a subset: extra keys are ignored, which suits event dictionaries and parsed JSON well. Sequence patterns match lists and tuples interchangeably and support one starred name, `*rest`, to absorb the remainder; strings and bytes are deliberately excluded so that 'abc' never matches `[a, b, c]`.
Two behaviours surprise people. First, a bare lowercase or uppercase name is a capture pattern: it always matches and rebinds that name, so `case LIMIT:` swallows every subject rather than comparing against the constant. Use a dotted name such as `Config.LIMIT` or an enum member, which is a value pattern compared with ==. Second, if no case matches, the whole statement is simply skipped with no error, so any variable you expected to be assigned stays unbound; add a final `case _:` when you want a guaranteed outcome.
def describe(event):
match event:
case {'type': 'click', 'pos': (x, y)}:
return f'click at {x},{y}'
case {'type': 'key', 'key': str() as k} if k.isdigit():
return f'digit {k}'
case {'type': 'key', 'key': k}:
return f'key {k}'
case [first, *rest]:
return f'batch of {1 + len(rest)} starting with {first!r}'
case _:
return 'unknown'
for event in [
{'type': 'click', 'pos': (3, 9)},
{'type': 'key', 'key': '7'},
{'type': 'key', 'key': 'esc'},
['a', 'b', 'c'],
42,
]:
print(describe(event))A case is a shape description that tests and unpacks the subject at the same time, not a boolean expression.
Worked examples
Matching object attributes
Class patterns test the type and pull out attributes, positionally when __match_args__ exists.
from dataclasses import dataclass
dataclass
class Point:
x: int
y: int
def where(p):
match p:
case Point(0, 0):
return 'origin'
case Point(x=0, y=y):
return f'on y-axis at {y}'
case Point(x, 0):
return f'on x-axis at {x}'
case Point():
return 'somewhere else'
print(where(Point(0, 0)))
print(where(Point(0, 5)))
print(where(Point(-3, 0)))
print(where(Point(2, 2)))Example explained
Line 1@dataclass generates __match_args__ = ('x', 'y'), which is what makes Point(0, 0) legal as a positional pattern.
Line 2Point(0, 0) contains two literal subpatterns, so it matches only when both attributes equal 0.
Line 3In Point(x=0, y=y) the left y is the attribute name and the right y is the name being bound; they are unrelated.
Line 4Point() has no subpatterns, so it acts as a type-only test and catches every remaining Point.
Capture pattern versus value pattern
Shows why a bare constant name matches everything and how a dotted name fixes it.
RED = 'red'
class Color:
RED = 'red'
GREEN = 'green'
def loose(c):
match c:
case RED:
return 'stop'
case _:
return 'other'
def strict(c):
match c:
case Color.RED:
return 'stop'
case _:
return 'other'
print(loose('green'))
print(strict('green'))
print(strict('red'))Example explained
Line 1`case RED:` is a capture pattern: it never reads the global RED, it just binds the subject, so it always succeeds.
Line 2That is why loose('green') reports 'stop' and the `case _:` branch is dead code.
Line 3`case Color.RED:` is a value pattern because the name is dotted, so Python compares subject == Color.RED.
Line 4strict therefore separates 'green' from 'red' correctly; enum members work the same way.
Or-patterns, as, and guards in a command parser
Alternatives, sub-pattern naming and a guard working together on a split command line.
def run(cmd):
match cmd.split():
case ['quit' | 'exit']:
return 'bye'
case ['go', ('north' | 'south' | 'east' | 'west') as direction]:
return f'walking {direction}'
case ['take', *items] if items:
return 'taking ' + ', '.join(items)
case [verb, *_]:
return f'I do not know how to {verb}'
case []:
return 'say something'
for line in ['exit', 'go north', 'take lamp key', 'dance a lot', '']:
print(run(line))Example explained
Line 1['quit' | 'exit'] matches a one-element list whose item is either literal, avoiding two near-identical cases.
Line 2The `as direction` clause names whichever alternative matched, so the branch body can use it.
Line 3`if items:` runs only after the pattern matched, so a bare 'take' falls through to the next case.
Line 4`case []` sits last but is still reachable, because [verb, *_] requires at least one element.
Important notes
Sequence patterns never match str, bytes or bytearray, so match a split list rather than the raw string when you want per-item patterns.
Mapping patterns ignore unlisted keys; if you need the leftovers, add `**rest` to the pattern to capture them as a dict.
Common mistakes
Writing `case MAX_RETRIES:` for a module-level constant; it is a capture pattern, so the first case swallows every subject, rebinds MAX_RETRIES, and later cases become unreachable.
Assuming match raises when no case fits; the statement is skipped, so a variable you only assign inside cases is never bound and a NameError appears further down.
Using `case Point(x, y)` on a plain class with no __match_args__, which raises TypeError; positional class patterns need __match_args__ or keyword subpatterns like Point(x=x).
Try it yourself
Change, predict, then run
Write a function `area(shape)` that matches dicts like {'kind': 'circle', 'r': 2}, {'kind': 'rect', 'w': 3, 'h': 4} and {'kind': 'square', 'side': 5}, returning the area and 'unsupported' for anything else. Add a guard so a negative dimension returns 'invalid'.
Open the Python workspaceCheck your understanding
STATUS_OK = 200 is defined at module level, and you write `case STATUS_OK:` as the first case of a match. Every subject matches that branch. Why?
- A bare name is a capture pattern, so it binds the subject instead of comparing against the constant
- match compares with `is`, and because small integers are interned the comparison always succeeds
- Constants must be annotated with Final, otherwise the pattern comparison step is skipped
- The branch matches only integers, but match coerces the subject to int before comparing
Show answer
An undotted name in a pattern position is a capture pattern: it always matches and assigns the subject to that name, so no comparison happens at all. That is why the `is`-interning explanation is wrong; there is nothing being compared. Writing a dotted name such as Status.OK, or an enum member, makes it a value pattern that is checked with ==.