Pattern matching with match and case
Express branching rules by the shape of data, add guards for conditions, and keep captures readable.
By the end, you’ll be able to…
- Distinguish a structural pattern from an equality comparison.
- Match sequences, mappings, and class instances with guards.
- Use wildcards and ordered cases without hiding simple logic.
01A branch can describe a shape
An if/elif chain asks a sequence of questions. A match statement compares one value against one or more patterns and runs the first case that fits. Patterns can do more than compare equality: a sequence pattern can unpack positions, a mapping pattern can require keys, and a class pattern can read attributes. The shape has to match before the branch is selected.
Read this command parser from top to bottom. The first case only matches a two-item list whose first item is the string 'move'. The names x and y are captures: they receive the remaining values. The wildcard case is a catch-all. Once a case matches, its block runs and the other cases are skipped; there is no implicit fall-through.
def describe(command): match command: case ["move", x, y]: return f"move to ({x}, {y})" case ["say", message] if message: return f"say: {message}" case {"kind": "quit"}: return "quit" case _: return "unknown command"print(describe(["move", 2, 5]))print(describe(["say", "hello"]))print(describe({"kind": "quit"}))print(describe(["say", ""]))move to (2, 5) say: hello quit unknown command
02Guards add a condition
A guard is an if expression written after a pattern. The pattern must match first, then the guard is evaluated with any names captured by that pattern. If the guard is false, matching continues with the next case. Guards keep shape checks and range checks together without turning every branch into a nested if statement.
Notice that the first numeric case is a literal and the later cases capture a value before testing it. A bare name in a pattern is a capture, not a comparison with an existing variable. That rule is one of the most common sources of confusion in pattern matching. Use literals, dotted names, or enum members when you mean a fixed value.
def classify(score): match score: case 0: return "zero" case n if n < 0: return "negative" case n if n < 60: return "review" case n if n <= 100: return "pass" case _: return "out of range"for value in (0, -3, 59, 60, 101): print(value, classify(value))0 zero -3 negative 59 review 60 pass 101 out of range
03Objects and mappings have shapes too
Dataclasses and ordinary classes can participate in class patterns. A dataclass gets a useful __match_args__ automatically, so Point(0, 0) can match its first two attributes positionally. You can also use keyword patterns such as Point(x=0, y=y), which makes the intent clearer when a class has several fields.
Mapping patterns are useful at data boundaries. They require the listed keys to exist but allow extra keys, which makes them a good fit for event payloads that may grow over time. Sequence patterns are stricter about the number of positions unless you use a starred capture. Choose the pattern that describes the contract you actually require.
from dataclasses import dataclass@dataclassclass Point: x: int y: intdef quadrant(point): match point: case Point(0, 0): return "origin" case Point(0, y): return f"y-axis at {y}" case Point(x, 0): return f"x-axis at {x}" case Point(x, y) if x > 0 and y > 0: return "quadrant I" case Point(): return "another quadrant"print(quadrant(Point(0, 0)))print(quadrant(Point(0, 7)))print(quadrant(Point(4, 0)))print(quadrant(Point(4, 3)))print(quadrant(Point(-2, 5)))origin y-axis at 7 x-axis at 4 quadrant I another quadrant
04Keep patterns small and exhaustive
Pattern matching is a tool for data with recognizable shapes, not a replacement for every if statement. A simple equality check, a range comparison, or a short if/elif chain is often easier to read. Reach for match when you are unpacking a command, classifying an event, walking a tree of records, or handling several record shapes through one interface.
Order cases from most specific to most general. Put a wildcard or a broad capture last so it does not hide a case you meant to write. A wildcard is not required, but without one a value that matches no pattern falls through silently. Add a final case that raises an error or returns an explicit 'unsupported' result when the input space should be closed.
def handle(event): match event: case {"type": "ready", "count": count} if count > 0: return f"ready with {count}" case {"type": "error", "message": message}: return f"error: {message}" case _: return "ignored"print(handle({"type": "ready", "count": 2}))print(handle({"type": "error", "message": "offline"}))print(handle({"type": "ready", "count": 0}))ready with 2 error: offline ignored
The ideas worth keeping.
- A match statement selects the first pattern that fits, not every pattern that fits.
- Guards and captures make data-shape rules explicit, but bare names always capture.
- Keep a deliberate fallback case and prefer if/elif when the data has no useful structure to unpack.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Route a command
Write route(command) that matches a two-item ['GET', path] command, a three-item ['POST', path, body] command, and returns 'unsupported' for anything else. Print one example of each.
Explore a worked solution
def route(command): match command: case ["GET", path]: return f"read {path}" case ["POST", path, body]: return f"write {path}: {body}" case _: return "unsupported"print(route(["GET", "/health"]))print(route(["POST", "/tasks", "ship it"]))print(route(["DELETE", "/tasks"]))Each sequence pattern states the required length and position. The wildcard keeps an unexpected shape from failing silently with no result.
Guard an event
Write accept(event) that requires a dictionary with type 'reading', a numeric value, and a value greater than or equal to zero. Return a formatted message for accepted readings and 'rejected' otherwise.
Explore a worked solution
def accept(event): match event: case {"type": "reading", "value": value} if isinstance(value, (int, float)) and value >= 0: return f"accepted {value}" case _: return "rejected"print(accept({"type": "reading", "value": 12}))print(accept({"type": "reading", "value": -1}))print(accept({"type": "other", "value": 4}))The mapping pattern checks the required keys, while the guard checks the value's type and range. Both conditions must pass before the first case is selected.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Route a command
Write route(command) that matches a two-item ['GET', path] command, a three-item ['POST', path, body] command, and returns 'unsupported' for anything else. Print one example of each.
route(["GET" "/health"]) route(["POST" /tasks "ship it"]) route(["DELETE" "/tasks"])
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.