Contents
13 Chapter

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 #

KindShapeMeaning
Literal patterncase 200:exact value match
Capture patterncase x:match anything and bind to a variable
Sequence patterncase [a, b, *rest]:list / tuple shape
Mapping patterncase {"key": v}:dict shape
Class patterncase 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 ==.

Literals
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 (.).

🚫 Interpreted as capture
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.

✅ Dotted form for constant comparison
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.

Enum is cleanest
from enum import Enum

class Status(Enum):
    SUCCESS = 200
    NOT_FOUND = 404

match status:
    case Status.SUCCESS:
        ...
    case Status.NOT_FOUND:
        ...

Capture variables #

Capture
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 #

🚫 Same name twice in one pattern
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.

✅ With 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.

Sequence patterns
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).

Strings don't match sequence patterns
match "hello":
    case [a, *rest]:
        print(a, rest)    # no match
    case "hello":
        print("hello!")   # this branch

Fixed 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.

Mapping pattern
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 #

Capture the rest
match config:
    case {"host": host, "port": port, **rest}:
        print(f"{host}:{port}, other options: {rest}")

Keys themselves must be literals #

🚫 Keys can't be captures
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 matching — basics
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__.

__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.

dataclass + match
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.

Built-in matching
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.

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 — | #

OR
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.

🚫 Can't OR different captures
match val:
    case [x] | [x, y]:    # ✗ y is not in the first pattern
        ...
✅ Same variables only
match val:
    case [x] | [x, _]:    # both capture only x
        ...

Guards — if #

We saw this briefly in Chapter 3.

Guard
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")    # default

It also doesn’t conflict across positions.

Multiple _
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.

Practical — branching API responses
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.

🚫 Forced match
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/elif is more natural
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 #

  1. With @dataclass, build three subclasses of Shape: Circle(radius: float), Rectangle(width: float, height: float), Triangle(base: float, height: float). Write def area(s: Shape) -> float: in a single match returning each shape’s area (leveraging the auto-generated __match_args__).
  2. 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 inside data is empty / has 100 or more items (combining mapping pattern + sequence pattern + guard).
  3. Take old-style code (something with five or more if isinstance(x, A): ... chains) and rewrite it with this chapter’s match. 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 **rest for the rest. Class uses __match_args__ positional matching (auto-generated by @dataclass). as captures 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.

X