Advanced · Lesson 58 of 94

Structural pattern matching

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

match is not a switch statement, although it can act as one. It compares a value against patterns, and a pattern can describe shape: a tuple of two items, a dictionary with certain keys, an instance of a class with certain attribute values. When a pattern matches, the names inside it are bound to the matching pieces, so testing and unpacking happen in one step. It arrived in Python 3.10 and is the natural way to handle commands, events, JSON-like messages, and dataclasses that come in several forms.

Structural pattern matching: match command: → case ['go', direction → case ['take', *items → case {'type': 'quit'}: → case Point(x=0, y=0): → case _: → direction bound → items bound as a list
Structural pattern matching · Figure 1 · Open for full size

Patterns come in a few kinds and compose. A literal pattern, case 404: or case "quit":, matches by equality. A capture pattern, a bare name, matches anything and binds it. A sequence pattern, case [x, y]:, matches a list or tuple of that length, and *rest collects the remainder. A mapping pattern, case {"type": "click", "pos": pos}:, matches a dictionary that has at least those keys. A class pattern, case Point(x=0, y=y):, matches an instance and its attributes. _ matches anything without binding it, and belongs last as the catch-all.

Pattern Example Matches when
literal case 0: / case "ok": equal to the value
capture case name: anything; binds name
sequence case [a, b, *rest]: list/tuple with ≥ 2 items
mapping case {"id": id_, **extra}: dictionary with an "id" key
class case Money(amount=0): instance with matching attributes
or case "y" | "yes": either alternative
guard case [x, y] if x == y: pattern matches and the condition holds
wildcard case _: anything, binds nothing

A guard adds a condition after the pattern, evaluated only if the shape matched. Alternatives with | let one case handle several literals or shapes. Enum members and dotted names like Status.PAID are treated as values to compare against, not names to bind, because they contain a dot; a bare name is always a capture, which is the one rule that surprises people. Dataclasses support class patterns by position as well as by keyword, because the decorator records the field order.

Reach for match when you are dispatching on the structure of data: parsing commands, handling messages with a type field, walking a tree of nodes, or handling several dataclass variants. Do not reach for it to replace a simple if x == 1 or a dictionary lookup; those stay clearer as they are. The test is whether the cases destructure something. If every case is a bare literal, a dictionary or an if chain is usually shorter.

Structural pattern matching: match/case → a dictionary lookup → if/elif
Structural pattern matching · Figure 2 · Open for full size

Code

A command parser: sequence patterns with literals, captures, and a star, plus | alternatives and the wildcard. Each command becomes readable in one line.

# commands.py
def handle(command):
    match command.split():
        case ["go", direction]:
            return f"walking {direction}"
        case ["take", *items] if items:
            return f"taking {', '.join(items)}"
        case ["look"] | ["l"]:
            return "you see a room"
        case ["quit" | "exit"]:
            return "bye"
        case []:
            return "say something"
        case _:
            return f"unknown: {command!r}"


for text in ["go north", "take lamp key", "l", "exit", "", "dance wildly"]:
    print(f"{text!r:<18} -> {handle(text)}")
Output
'go north'         -> walking north
'take lamp key'    -> taking lamp, key
'l'                -> you see a room
'exit'             -> bye
''                 -> say something
'dance wildly'     -> unknown: 'dance wildly'

Mapping patterns for JSON-like events. Extra keys are allowed, **rest collects them, and a nested pattern reaches inside.

# events.py
events = [
    {"type": "click", "pos": {"x": 10, "y": 20}, "button": "left"},
    {"type": "key", "key": "Enter"},
    {"type": "click", "pos": {"x": 0, "y": 0}},
    {"type": "scroll", "delta": -3, "shift": True},
]

for event in events:
    match event:
        case {"type": "click", "pos": {"x": 0, "y": 0}}:
            print("click at origin")
        case {"type": "click", "pos": {"x": x, "y": y}, **rest}:
            print(f"click at ({x}, {y}) extras={sorted(rest)}")
        case {"type": "key", "key": key}:
            print(f"key {key}")
        case {"type": kind}:
            print(f"other event: {kind}")
Output
click at (10, 20) extras=['button']
key Enter
click at origin
other event: scroll

Class patterns over dataclasses and an enum, with guards. Dotted names like Status.PAID compare; bare names bind.

# shapes_match.py
from dataclasses import dataclass
from enum import Enum


class Status(Enum):
    PAID = "paid"
    REFUNDED = "refunded"


@dataclass
class Payment:
    amount: float
    status: Status


@dataclass
class Point:
    x: float
    y: float


def describe(obj):
    match obj:
        case Point(x=0, y=0):
            return "origin"
        case Point(x=0, y=y) | Point(x=y, y=0):
            return f"on an axis at {y}"
        case Point(x, y) if x == y:
            return f"on the diagonal at {x}"
        case Point():
            return "somewhere else"
        case Payment(amount=a, status=Status.PAID) if a > 100:
            return f"large payment of {a}"
        case Payment(status=Status.REFUNDED):
            return "refunded"
        case Payment():
            return "small payment"
        case _:
            return "not a shape or payment"


things = [
    Point(0, 0), Point(0, 5), Point(3, 3), Point(1, 2),
    Payment(250, Status.PAID), Payment(5, Status.PAID), Payment(5, Status.REFUNDED), "x",
]
for thing in things:
    print(describe(thing))
Output
origin
on an axis at 5
on the diagonal at 3
somewhere else
large payment of 250
small payment
refunded
not a shape or payment

Walking a tree: nested tuples as an expression, evaluated with recursive match. This is the kind of code where match is clearly better than the alternative.

# expr_tree.py
def evaluate(expr):
    match expr:
        case int() | float():
            return expr
        case ("add", left, right):
            return evaluate(left) + evaluate(right)
        case ("mul", left, right):
            return evaluate(left) * evaluate(right)
        case ("neg", inner):
            return -evaluate(inner)
        case _:
            raise ValueError(f"bad expression: {expr!r}")


tree = ("add", ("mul", 3, 4), ("neg", ("add", 1, 1)))
print(evaluate(tree))
print(evaluate(("mul", 2.5, ("add", 1, 1))))
try:
    evaluate(("pow", 2, 3))
except ValueError as err:
    print(err)
Output
10
5.0
bad expression: ('pow', 2, 3)

Try it yourself

Create router.py with a route(request) function where a request is a tuple like ("GET", ["users", "42"]). Use match to return "list users" for ("GET", ["users"]), "user 42" for a GET with an id, "create user" for a POST to users, "not found" for anything else, and "method not allowed" for a DELETE to any path using a guard. Then write an Event dataclass hierarchy of two or three variants and a handler that matches on them.

Common mistakes

What's next

You can dispatch on structure. Next you will add the type information that makes both readers and tools understand that structure: type hints.

Course overview