Structural Pattern Matching in Python: A Practical Deep Dive into match/case
Go beyond switch statements. Learn how Python's match/case destructures sequences, mappings, and objects — with capture patterns, class patterns, guards, and the pitfalls that trip people up.
If you came to Python from C, Java, or JavaScript, you probably missed a switch statement at some point — and then learned to live with long if/elif chains instead. Python 3.10 introduced something far more powerful: structural pattern matching via the match and case keywords. It is not just a switch. It can look inside your data — destructuring tuples, lists, dictionaries, and objects — and bind variables in the process. Used well, it turns sprawling conditional logic into something that reads like a description of your data's shape.
This guide walks through the feature from the ground up, with runnable examples, the idioms that make it shine, and the pitfalls that catch almost everyone the first time.
The basics: literals, alternatives, and the wildcard
At its simplest, match compares a subject value against a series of patterns, top to bottom, and runs the first block that matches. The | symbol combines alternatives, and _ is the wildcard that matches anything — Python's equivalent of a default case.
def http_status(code):
match code:
case 200 | 201 | 204:
return "Success"
case 301 | 302:
return "Redirect"
case 400 | 401 | 403 | 404:
return "Client error"
case 500:
return "Server error"
case _:
return "Unknown"
print(http_status(200)) # Success
print(http_status(404)) # Client error
print(http_status(999)) # Unknown
So far this is just a nicer-looking switch. The real power shows up when patterns start describing structure.
Capture patterns: matching shape and binding values
A pattern can match the shape of a value and simultaneously bind parts of it to names. Here we match 2-tuples and capture their components:
def describe(point):
match point:
case (0, 0):
return "Origin"
case (0, y):
return f"On the Y axis at {y}"
case (x, 0):
return f"On the X axis at {x}"
case (x, y):
return f"Point at ({x}, {y})"
case _:
return "Not a point"
print(describe((0, 0))) # Origin
print(describe((0, 5))) # On the Y axis at 5
print(describe((2, 4))) # Point at (2, 4)
Notice that (0, y) matches only when the first element is literally 0, and then binds the second element to y. This blend of literal matching and capturing is what makes pattern matching expressive.
Sequence patterns and the star
Sequence patterns work on lists and tuples (but, deliberately, not on strings — a string is treated as an atomic value, not a sequence of characters). You can use a starred name to capture "the rest," much like unpacking assignments.
def handle(command):
match command.split():
case ["go", direction]:
return f"Going {direction}"
case ["drop", *items]:
return f"Dropping {', '.join(items)}"
case ["quit"]:
return "Quitting"
case _:
return "Unknown command"
print(handle("go north")) # Going north
print(handle("drop sword shield potion")) # Dropping sword, shield, potion
print(handle("quit")) # Quitting
This is a natural fit for parsing tokenized input, REPL commands, or simple interpreters.
Mapping patterns: matching dictionaries
Mapping patterns match dictionaries by key. A crucial detail: they match on the presence of the listed keys, not on an exact set — extra keys in the subject are ignored. This makes them perfect for handling JSON-like event payloads where you only care about a few fields.
def parse_event(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "key", "key": k}:
return f"Key {k}"
case {"type": t}:
return f"Other event: {t}"
case _:
return "Malformed"
print(parse_event({"type": "click", "x": 10, "y": 20})) # Click at (10, 20)
print(parse_event({"type": "key", "key": "Esc"})) # Key Esc
print(parse_event({"type": "scroll", "delta": 3})) # Other event: scroll
Class patterns: destructuring objects
Class patterns are where pattern matching feels genuinely new. You can match against a type and pull out its attributes. Dataclasses work out of the box because they generate the metadata pattern matching needs.
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 * r
case Rectangle(width=w, height=h) if w == h:
return f"Square with area {w * h}"
case Rectangle(width=w, height=h):
return w * h
case _:
raise ValueError("Unknown shape")
print(area(Circle(2))) # 12.56636
print(area(Rectangle(3, 3))) # Square with area 9
print(area(Rectangle(2, 5))) # 10
The if w == h clause is a guard: an extra boolean condition that must also be true for the case to match. Guards let you express constraints that go beyond structure.
Positional class patterns and __match_args__
You can match class attributes positionally rather than by keyword, but only if the class defines __match_args__ to declare the order. Dataclasses set this for you automatically based on field order, so Point(0, 0) below works without any extra code.
@dataclass
class Point:
x: int
y: int
def quadrant(p):
match p:
case Point(0, 0):
return "origin"
case Point(x, y) if x > 0 and y > 0:
return "Q1"
case Point(x, y):
return "other"
print(quadrant(Point(0, 0))) # origin
print(quadrant(Point(1, 1))) # Q1
print(quadrant(Point(-1, 2))) # other
Nesting, the as pattern, and enums
Patterns compose. You can nest mappings inside classes inside sequences, and use as to capture a whole matched value while still constraining its shape. The str(name) pattern below both checks that name is a string and binds it.
def classify(data):
match data:
case {"user": {"name": str(name), "role": "admin"}}:
return f"Admin: {name}"
case {"user": {"name": str(name)}}:
return f"User: {name}"
case [Circle() as c, *_]:
return f"List starting with circle r={c.radius}"
case _:
return "Other"
print(classify({"user": {"name": "Ada", "role": "admin"}})) # Admin: Ada
print(classify({"user": {"name": "Bob"}})) # User: Bob
print(classify([Circle(5), Rectangle(1, 1)])) # List starting with circle r=5
Enums pair beautifully with match because each member is a dotted, constant value:
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
def to_hex(c):
match c:
case Color.RED:
return "#ff0000"
case Color.GREEN:
return "#00ff00"
case Color.BLUE:
return "#0000ff"
print(to_hex(Color.RED)) # #ff0000
The pitfall everyone hits: capture vs. comparison
This is the single most important thing to internalize. A bare name in a pattern is a capture pattern — it matches anything and binds the value to that name. It does not compare against a variable of the same name. Beginners often write something like this expecting a comparison:
RED = "#ff0000"
def bad(color):
match color:
case RED: # NOT a comparison to the RED above!
return f"captured: {RED}"
print(bad("literally anything")) # captured: literally anything
The case RED here rebinds RED to whatever was passed in and always matches. In fact, if you add more cases after it, Python is smart enough to refuse: it raises SyntaxError: name capture 'RED' makes remaining patterns unreachable. To compare against a constant, use a dotted name (like an enum member or module.CONSTANT) or a guard:
RED = "#ff0000"
def good(color):
match color:
case c if c == RED: # explicit comparison via a guard
return "is red"
case _:
return "other"
print(good("#ff0000")) # is red
print(good("blue")) # other
When to reach for it (and when not to)
Pattern matching earns its keep when you are branching on the structure of data: parsing ASTs, interpreting commands, handling heterogeneous JSON, or implementing the visitor-style dispatch common in compilers and protocol handlers. For a plain equality check on a single value, a dictionary lookup or a short if/elif is often clearer — don't reach for match just because it's new.
A few practical tips to keep in mind:
- Order matters. Cases are tested top to bottom; put the most specific patterns first and broader ones last.
- Add a
case _when you want a guaranteed fallback. Without one, amatchthat finds no match simply does nothing — which can hide bugs. - Strings and bytes are not treated as sequences, so
case [x, y]will never match a string. Split or convert first. - Mapping patterns match a subset of keys; if you need exactness, check
len()in a guard.
Wrap-up and next steps
Structural pattern matching gives Python a declarative way to describe and deconstruct data in one stroke. Start by replacing a gnarly if/elif chain that branches on tuples or dictionaries, get comfortable with capture patterns and guards, then graduate to class patterns with dataclasses. Once the capture-vs-comparison rule clicks, you'll find match reads like documentation for the shapes your code actually handles. For the full specification, the official tutorial in What's New in Python 3.10 and PEPs 634–636 are the canonical references.