Pattern matching in depth
The next step from basics match-case — class patterns and __match_args__, sequence/mapping patterns, captures and guards, and the antipatterns to avoid.
Chapter 3 control flow gave us a brief look at match-case — through simple branches, OR patterns, destructuring, class matching, and guards. This chapter is the next step. It gathers every pattern kind in one place, covers the user-defined __match_args__ hook, and points out the pitfalls people commonly run into.
The class patterns in this chapter pair up with Chapter 8 dataclass — because @dataclass auto-generates __match_args__. The discriminated union from Chapter 9 typing in earnest also combines naturally with the mapping patterns in this chapter.
Five pattern categories #
| Kind | Shape | Meaning |
|---|---|---|
| Literal pattern | case 200: | exact value match |
| Capture pattern | case x: | match anything and bind to a variable |
| Sequence pattern | case [a, b, *rest]: | list / tuple shape |
| Mapping pattern | case {"key": v}: | dict shape |
| Class pattern | case Point(x=0): | class + attribute match |
These five combine to express almost every case. Let’s go through them one at a time.
Literals and value comparison #
Literal patterns compare with ==.
match status:
case 200:
print("OK")
case "active":
print("active")
case True:
print("yes")
case None:
print("none")Dotted notation — comparison with a constant #
Writing just case ABC: makes it a capture variable. To compare with a constant, there has to be a dot (.).
SUCCESS = 200
match code:
case SUCCESS: # ⚠ binds code to a variable called SUCCESS!
print("ok")
case _:
print("else")SUCCESS matches every value, so the second case never runs. Tools warn about this.
class Status:
SUCCESS = 200
NOT_FOUND = 404
match code:
case Status.SUCCESS:
print("ok")
case Status.NOT_FOUND:
print("not found")When using constants in matching, putting them as enum values or class attributes is the safe pattern.
from enum import Enum
class Status(Enum):
SUCCESS = 200
NOT_FOUND = 404
match status:
case Status.SUCCESS:
...
case Status.NOT_FOUND:
...Capture variables #
match command:
case x:
print(f"received command: {x}")A lowercase name (more precisely, any identifier without a dot) is a capture. It matches everything and binds to the variable. _ (underscore) is the one exception — it’s a wildcard, so it doesn’t bind.
Can’t use a capture variable twice #
match (a, b):
case (x, x): # ✗ SyntaxError
...Using the same variable name twice in one pattern is an error. To check whether the values are equal, use a guard.
match (a, b):
case (x, y) if x == y:
...Sequence patterns #
You can write the shape of lists and tuples as a pattern.
match items:
case []:
print("empty list")
case [x]:
print(f"only one: {x}")
case [x, y]:
print(f"two: {x}, {y}")
case [first, *rest]:
print(f"head {first}, rest {rest}")
case [*init, last]:
print(f"front {init}, last {last}")
case [first, *middle, last]:
print(f"{first}, {middle}, {last}")*name captures the rest in one go — same as regular unpacking. Matching accepts both list and tuple. But str and bytes, though sequences, are excluded from matching (to prevent unintended matches).
match "hello":
case [a, *rest]:
print(a, rest) # no match
case "hello":
print("hello!") # this branchFixed length vs variable #
[a, b, c] — exactly length 3
[a, b, *rest] — 3 or more
[*items] — any length (effectively the same as capture)
Mapping patterns #
Partial matching for dicts. If the keys you list are all present, it matches; other keys are ignored.
event = {"type": "click", "x": 10, "y": 20, "extra": "ignored"}
match event:
case {"type": "click", "x": x, "y": y}:
print(f"({x}, {y})")
case {"type": "key", "code": code}:
print(f"key {code}")Mapping patterns are partial matches. {"type": "click"} means “if this dict has a type key equal to click, OK.” Other keys can be present and it still matches.
Capture the rest with **rest
#
match config:
case {"host": host, "port": port, **rest}:
print(f"{host}:{port}, other options: {rest}")Keys themselves must be literals #
match data:
case {key: value}: # ✗ key is not a capture
...The key position in a mapping pattern is always a literal or a dotted form. It can’t be captured.
Class patterns #
The form we briefly saw in Chapter 3 control flow:
class Circle:
def __init__(self, radius: float):
self.radius = radius
match shape:
case Circle(radius=r):
print(f"circle, radius {r}")Matched via keyword arguments. The position is the attribute name.
__match_args__ — positional matching
#
To match by positional arguments like Point(0, 0), the class has to declare __match_args__.
class Point:
__match_args__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
match p:
case Point(0, 0):
print("origin")
case Point(0, y):
print(f"on y axis, y={y}")
case Point(x, 0):
print(f"on x axis, x={x}")
case Point(x, y):
print(f"({x}, {y})")@dataclass generates it automatically
#
The @dataclass from Chapter 8 auto-generates __match_args__. That is why dataclass and pattern matching work well together.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
match p:
case Point(0, 0): # works automatically
print("origin")Built-ins also match — but with only one argument #
Built-ins like int, str, and bool match the value itself with one positional argument.
match x:
case int(n):
print(f"integer {n}")
case str(s):
print(f"string {s}")
case list() if not x: # empty list
print("empty list")int(n) means “capture an int value into n.”
Capture vs comparison — the as keyword
#
When you want to bind the whole matched value to a variable, use as.
match shape:
case Circle() as c:
register(c)
case Square(side=s) as sq if s > 10:
register(sq)Useful when you need to reference the original value once more.
OR patterns — |
#
match status:
case 200 | 201 | 204:
print("success")
case 400 | 404 | 422:
print("client error")Inside an OR pattern, every alternative must capture the same variables.
match val:
case [x] | [x, y]: # ✗ y is not in the first pattern
...match val:
case [x] | [x, _]: # both capture only x
...Guards — if
#
We saw this briefly in Chapter 3.
match (x, y):
case (a, b) if a == b:
...
case (a, b) if a > b:
...Guards combine with every pattern kind. They’re for checking extra conditions on captured variables.
Wildcard — _
#
_ matches without capturing. case _: plays the default role.
match status:
case 200:
print("OK")
case _:
print("else") # defaultIt also doesn’t conflict across positions.
match items:
case [_, _, third]:
print(f"third: {third}")x, x doesn’t work but _, _ does — because _ doesn’t capture.
Combined example — JSON handling #
A case that branches an API response across many shapes. This sits in the same spot as the discriminated union in Chapter 24 Pydantic v2 in depth.
def handle(response: dict) -> str:
match response:
# Success case — has data and a fixed type
case {"status": "ok", "data": {"items": list(items), "total": int(total)}}:
return f"received: {len(items)} / total {total}"
# When pagination is present
case {"status": "ok", "data": {"items": items, "next_cursor": str(cursor)}}:
return f"next page cursor: {cursor}"
# Empty result
case {"status": "ok", "data": {"items": []}}:
return "no results"
# Error — code + message
case {"status": "error", "code": int(code), "message": str(msg)}:
return f"error {code}: {msg}"
# Anything else
case _:
return "unknown response"Writing the same thing with if/elif scatters type check + key existence check + value capture all over the place. match collapses the three into one line.
When match-case doesn’t fit
#
It’s not a silver bullet. A plain if is often more readable.
match score:
case s if s >= 90:
grade = "A"
case s if s >= 80:
grade = "B"
case s if s >= 70:
grade = "C"
case _:
grade = "F"if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"Decision rule: branching by shape, or by value range. Keyword-y shapes go to match; simple comparisons usually go to if/elif.
Exercises #
- With
@dataclass, build three subclasses ofShape:Circle(radius: float),Rectangle(width: float, height: float),Triangle(base: float, height: float). Writedef area(s: Shape) -> float:in a singlematchreturning each shape’s area (leveraging the auto-generated__match_args__). - Write a function handling both API response shapes
{"status": "ok", "data": [...]}and{"status": "error", "code": int, "message": str}. Split out additional branches for when the list insidedatais empty / has 100 or more items (combining mapping pattern + sequence pattern + guard). - Take old-style code (something with five or more
if isinstance(x, A): ...chains) and rewrite it with this chapter’smatch. Compare how much shorter and clearer the intent gets.
In one line: Five patterns — literal / capture / sequence / mapping / class. Constant comparison needs dotted form (
Status.OK). Sequence covers list/tuple only; str/bytes are excluded. Mapping is partial match with**restfor the rest. Class uses__match_args__positional matching (auto-generated by@dataclass).ascaptures the whole,|is OR (same variables only), guards combine with every pattern. Branch by shape with match, by value range with if/elif.
Next chapter #
The final chapter of Part 2, Chapter 14 asyncio intro, covers asyncio and async/await. The coroutines we briefly met in Chapter 11 take center stage. It’s the foundation for Chapter 18 asyncio in depth in Part 3.