Pattern matching with match/case¶
In this tutorial, you will learn to use match/case, Python's structural pattern matching syntax (introduced in Python 3.10). You will see when it makes code clearer than if/elif, and when it doesn't.
Time commitment: 15–20 minutes
Prerequisites:
- Completion of If statements
- Completion of Boolean operators and truthiness
- Comfort with Python tuples, lists, and dictionaries
Learning objectives¶
By the end of this tutorial, you will be able to:
- Read and write
match/casestatements - Use literal, capture, sequence, mapping, and class patterns
- Combine patterns with
|(OR) andifguards - Recognise the "capture vs compare" trap
- Decide when
matchis clearer thanif/elif
The motivation¶
Imagine you're handling events from a UI. Each event is a dict with a "type" key and other fields that depend on the type. The if/elif version looks like this:
def handle_if(event):
if event["type"] == "click":
print(f"clicked at ({event['x']}, {event['y']})")
elif event["type"] == "keypress":
print(f"key pressed: {event['key']}")
elif event["type"] == "scroll":
print(f"scrolled by {event['delta']}")
else:
print(f"unknown event: {event}")
handle_if({"type": "click", "x": 10, "y": 20})
handle_if({"type": "keypress", "key": "Enter"})
It works, but each branch repeats the same shape: check the type, then dig into specific keys. Pattern matching lets you describe the shape and the destructuring in one go.
def handle_match(event):
match event:
case {"type": "click", "x": x, "y": y}:
print(f"clicked at ({x}, {y})")
case {"type": "keypress", "key": key}:
print(f"key pressed: {key}")
case {"type": "scroll", "delta": delta}:
print(f"scrolled by {delta}")
case _:
print(f"unknown event: {event}")
handle_match({"type": "click", "x": 10, "y": 20})
handle_match({"type": "keypress", "key": "Enter"})
The match version reads as a list of shapes the function knows how to handle. The keys mentioned in each pattern are also the names you use inside the body. There's no separate "check, then unpack" step.
The basic shape¶
match subject:
case pattern_1:
...
case pattern_2:
...
case _:
... # wildcard — matches anything
Python evaluates subject once, then tries each case in order. The first matching pattern wins. There is no fall-through (unlike switch in C-family languages), so you don't need break.
Literal patterns¶
The simplest patterns match exact values: numbers, strings, booleans, and None.
def describe_status(code):
match code:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Server Error"
case _:
return f"unhandled status {code}"
print(describe_status(200))
print(describe_status(404))
print(describe_status(418))
For these cases, match doesn't buy you much over if/elif. Where it earns its place is when the patterns describe shape as well as value.
Capture patterns¶
A bare name in a case pattern doesn't compare — it captures. The name is bound to whatever the subject is.
def describe_pair(pair):
match pair:
case (0, 0):
return "origin"
case (x, y):
return f"point at ({x}, {y})"
print(describe_pair((0, 0)))
print(describe_pair((3, 4)))
In the second case, x and y are not pre-existing variables — they're being created by the match. After the match, you can use them inside the body.
The wildcard _ is a special capture: it matches anything but binds nothing.
Sequence patterns¶
Sequence patterns match tuples and lists. You can match by length or by prefix-and-rest.
def describe_items(items):
match items:
case []:
return "empty"
case [single]:
return f"one item: {single}"
case [first, second]:
return f"two items: {first}, {second}"
case [first, *rest]:
return f"{first} and {len(rest)} more"
print(describe_items([]))
print(describe_items(["apple"]))
print(describe_items(["apple", "pear"]))
print(describe_items(["apple", "pear", "plum", "fig"]))
Note that strings are not treated as sequences for matching purposes. case [a, b, c]: matches a list or tuple of three items, not the string "abc".
Mapping patterns¶
Mapping patterns match dicts. Only the keys you mention need to be present — extra keys are ignored.
def describe_user(user):
match user:
case {"name": name, "admin": True}:
return f"{name} is an admin"
case {"name": name, "guest": True}:
return f"{name} is a guest"
case {"name": name}:
return f"{name} is a regular user"
case _:
return "unknown user record"
print(describe_user({"name": "Ada", "admin": True}))
print(describe_user({"name": "Ben", "guest": True}))
print(describe_user({"name": "Cas", "joined": "2024-01-15"}))
Order matters: the more specific patterns come first, so the catch-all {"name": name} doesn't grab the admin and guest cases.
Class patterns¶
Class patterns let you match by type and bind attributes. They're especially useful with dataclasses.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
centre: Point
radius: float
def describe_shape(shape):
match shape:
case Point(x=0, y=0):
return "origin"
case Point(x=x, y=y):
return f"point at ({x}, {y})"
case Circle(centre=Point(x=cx, y=cy), radius=r):
return f"circle at ({cx}, {cy}) with radius {r}"
case _:
return "unknown shape"
print(describe_shape(Point(0, 0)))
print(describe_shape(Point(3, 4)))
print(describe_shape(Circle(centre=Point(1, 2), radius=5)))
Patterns can nest: Circle(centre=Point(x=cx, y=cy), ...) reaches into the centre attribute and matches a Point inside it. This is where match really starts to do work if/elif can't easily replicate.
OR patterns¶
Combine alternatives with |. The case matches if any alternative matches.
def kind_of_day(day):
match day:
case "Saturday" | "Sunday":
return "weekend"
case "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday":
return "weekday"
case _:
return "unknown day"
print(kind_of_day("Saturday"))
print(kind_of_day("Wednesday"))
All the alternatives in an OR pattern must bind the same names (or none). You can't have one branch capture x and another capture y — Python wouldn't know which name to bind.
Guarded patterns¶
A pattern can be followed by if guard: — an extra condition that has to hold for the case to match.
def describe_pair(pair):
match pair:
case (x, y) if x == y:
return f"on the diagonal at {x}"
case (x, 0):
return f"on the x-axis at x={x}"
case (0, y):
return f"on the y-axis at y={y}"
case (x, y):
return f"point at ({x}, {y})"
print(describe_pair((3, 3)))
print(describe_pair((5, 0)))
print(describe_pair((0, 7)))
print(describe_pair((1, 2)))
The guard runs only after the pattern structure has matched. It's the right tool when "the shape is right, but I also need to compare the captured values".
The capture trap¶
This is the most common stumbling block with match. A bare name in a case pattern doesn't compare against the existing variable of that name — it captures, binding the name to whatever the subject is.
Try running the cell below.
STATUS_OK = 200
def check_status(code):
match code:
case STATUS_OK:
return "all good"
print(check_status(200)) # all good — as expected
print(check_status(500)) # also "all good" — NOT what you want!
Both calls returned "all good". Why?
The STATUS_OK in case STATUS_OK: is a capture pattern, not a value comparison. Python sees a bare name and binds it to whatever code is — overwriting the local STATUS_OK in the process. The pattern always matches.
Python actually has a guard against the most obvious form of this. If you add a second case after a bare-name capture, the compiler catches it as a SyntaxError:
match code:
case STATUS_OK: # captures everything...
return "all good"
case _: # ...so this is unreachable
return "something else"
# SyntaxError: name capture 'STATUS_OK' makes remaining patterns unreachable
That helpful error doesn't fire when the capture is the only case (as above), or when a guard hides the unreachability. The trap is real; the compiler only catches the most obvious shape of it.
To compare against a constant, use a dotted name — Python treats those as value patterns, not captures:
class Status:
OK = 200
NOT_FOUND = 404
def check_status(code):
match code:
case Status.OK: # dotted name — value comparison
return "all good"
case Status.NOT_FOUND:
return "not found"
case _:
return "something else"
print(check_status(200))
print(check_status(404))
print(check_status(500))
Or use a guard:
case x if x == STATUS_OK:
return "all good"
Or just write the value directly: case 200:. The dotted-name rule is the cleanest fix when you're working with named constants.
Exercise: rewrite an event handler¶
Here is an if/elif version of an event handler. Rewrite it using match/case.
def handle_event_if(event):
if isinstance(event, dict) and event.get("type") == "message":
if "from" in event and "text" in event:
return f"{event['from']}: {event['text']}"
if isinstance(event, dict) and event.get("type") == "join":
if "user" in event:
return f"{event['user']} joined"
if isinstance(event, dict) and event.get("type") == "leave":
if "user" in event:
return f"{event['user']} left"
return "unknown event"
# Tests
print(handle_event_if({"type": "message", "from": "Ada", "text": "hello"}))
print(handle_event_if({"type": "join", "user": "Ben"}))
print(handle_event_if({"type": "leave", "user": "Cas"}))
print(handle_event_if({"type": "unknown"}))
# Write your match-based version here
def handle_event_match(event):
pass
# Tests
print(handle_event_match({"type": "message", "from": "Ada", "text": "hello"}))
print(handle_event_match({"type": "join", "user": "Ben"}))
print(handle_event_match({"type": "leave", "user": "Cas"}))
print(handle_event_match({"type": "unknown"}))
Solution¶
The mapping patterns let you check the type and destructure the relevant fields in one pattern:
def handle_event_match(event):
match event:
case {"type": "message", "from": sender, "text": text}:
return f"{sender}: {text}"
case {"type": "join", "user": user}:
return f"{user} joined"
case {"type": "leave", "user": user}:
return f"{user} left"
case _:
return "unknown event"
print(handle_event_match({"type": "message", "from": "Ada", "text": "hello"}))
print(handle_event_match({"type": "join", "user": "Ben"}))
print(handle_event_match({"type": "leave", "user": "Cas"}))
print(handle_event_match({"type": "unknown"}))
Notice how the four branches each describe the shape of the event they handle — there's no separate isinstance check or event["..."] indexing. The match version is shorter and the intent is more visible.
When to reach for match (and when not to)¶
match shines when:
- You're dispatching on structured data — dicts with type fields, dataclass instances, parsed messages, AST nodes
- You'd otherwise nest
isinstancechecks and attribute access - The set of cases is closed and enumerable at the point of the match
It's not the right tool when:
- You're checking a single value against a few options —
if x == 1 ... elif x == 2:is perfectly clear - Conditions involve arithmetic or method calls on the subject —
if score >= 0.8:isn't somethingmatchpatterns express naturally - You want to compare against runtime values bound to local names (because of the capture trap above)
The Choose between if/elif chains, dict dispatch, and match/case recipe walks through the trade-offs with worked examples.
Summary¶
In this tutorial, you learned how to:
- Write
match/casestatements to dispatch on structure as well as value - Use literal, capture, sequence, mapping, and class patterns
- Combine patterns with
|and refine them withifguards - Avoid the capture-vs-compare trap by using dotted names for constants
- Recognise the situations where
matchis clearer thanif/elif, and the situations where it isn't
What is next¶
That's all three Learn tutorials in this guide. To go deeper, explore the other sections: