Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate Python 3.10+ ~27 min· topic 8 of 9

Topic 10.8

Pattern Matching in Depth

In one line

match can take apart objects as well as values: class patterns pull out attributes, dataclasses work with positional patterns, and nested patterns, as, guards and assert_never make parsing commands, JSON events and typed unions short and safe.

Think of it like this

A post office sorting machine. Each letter goes by shape: a postcard here, a parcel there, a letter with a stamp from abroad somewhere else. Pattern matching sorts data by its shape and, in the same step, pulls out the parts you want to use.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Class pattern
A case like Point(x=1) that matches objects of a class with certain attribute values.
Capture
A name in a pattern that takes the matching part of the value, such as r in Circle(radius=r).
Guard
An if condition after a pattern that must also be true.
`__match_args__`
A tuple of attribute names that tells match the order for positional patterns.
Mapping pattern
A {key: pattern} case that matches dictionaries containing those keys.
`as` pattern
Gives a name to the whole matched value.
Exhaustive
Covering every possible case, so none is left unhandled.
`assert_never`
A function that marks code as unreachable; checkers verify it, and it raises if it runs.

Step by step

01Match an object's shape

Define small dataclasses (Topic 8.8: classes whose fields Python sets up for you), then describe each shape with a case. Notice that Circle(center=Point(x=0, y=0), radius=r) matches only a circle exactly at the origin, and captures the radius.

main.pywhole filepython
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

@dataclass
class Circle:
    center: Point
    radius: float

def describe(shape) -> str:
    match shape:
        case Circle(center=Point(x=0, y=0), radius=r):
            return f"circle at the origin, radius {r}"
        case Circle(Point(x, y), r) if r > 10:
            return f"big circle at ({x}, {y})"
        case Circle(c, r):
            return f"circle at {c}, radius {r}"
        case _:
            return "unknown shape"

02Order matters

The three circle cases go from most specific to most general. If case Circle(c, r) came first, it would catch every circle and the other two would never run. A guard (if r > 10) lets you refine a pattern without a new class.

03Positional patterns and __match_args__

Circle(Point(x, y), r) is positional: Python looks up Circle.__match_args__, which a dataclass sets to ('center', 'radius'). A plain class has none, so you either use keyword patterns or define __match_args__ yourself (the Pair class in the first example does).

04Parsing JSON events

A mapping pattern checks that keys exist and takes their values. This is the natural way to handle messages of many shapes, such as the events an LLM sends back one by one or a notification another service posts to your program (a webhook): a message, a tool call, an error. The JSON text becomes a dict with json.loads (Topic 7.7). Notice the order: the pattern with a city is tried before the general tool call.

main.pywhole filepython
def handle(event: dict) -> str:
    match event:
        case {"type": "message", "role": "user" | "assistant" as role, "content": str(text)}:
            return f"{role} says {text!r}"
        case {"type": "tool_call", "name": name, "args": {"city": city}}:
            return f"call {name} for {city}"
        case {"type": "tool_call", "name": name}:
            return f"call {name} without a city"
        case [first, *others]:
            return f"a list starting with {first!r}, then {len(others)} more"
        case _:
            return "ignored"

05Exhaustiveness with assert_never

Define a union of the possible classes (Expr = Add | Neg) and end the match with case _: assert_never(e). The checker proves every member is handled; if you add Mul and forget its case, the checker reports it. The runtime guard turns the same mistake into an AssertionError message that shows the unexpected value.

06Traps

A bare name always matches. case MAX: does not compare with a constant called MAX; it binds the value to a new variable named MAX and matches everything. Use a dotted name (case Limits.MAX:) or a literal. Strings are not sequences for the list pattern, so [a, b] does not match "hi". A mapping pattern ignores extra keys, so test for what you require, not what is absent.

Try it yourself

  1. 1

    Add a shape

    In the first example, add a Square dataclass with side and a case Square(side=s) returning its area. Put it before case _. What would happen if you put it after?

  2. 2

    Forget a union member

    In the second example, add class Mul with a and b, change the union to Add | Neg | Mul, and run evaluate(Mul(2, 3)). You get the AssertionError. With mypy installed, the checker would have reported the missing case before the run.

Code & diagrams

Class patterns, nesting, guards and as Python 3.10+ New tab

Dataclasses get __match_args__ for free. The Pair class defines its own. Cases are tried in order.

Sign in to run this example in your browser.

Expected output

circle at the origin, radius 2.0
big circle at (3, 4)
circle at Point(x=3, y=4), radius 1.0
rect 3.0x4.0 at (1, 2), area 12.0
pair 'a' and 1
unknown shape
dataclass __match_args__: ('x', 'y')
JSON events and exhaustive matching Python 3.11+ New tab

Mapping patterns for JSON shaped like LLM events, then assert_never (3.11) on a union of classes. The last call reaches assert_never on purpose.

Sign in to run this example in your browser.

Expected output

user says 'Hi'
call weather for Pune
call clock without a city
bad message, extra keys: ['content', 'role']
a list starting with 1, then 2 more
ignored
5 -7
AssertionError: Expected code to be unreachable, but got: Neg(a=1)
The trap: a bare name matches everythingpython

Python refuses this at compile time when it can see the mistake. Use a dotted name or a literal.

MAX = 10

match 5:
    case MAX:       # SyntaxError: name capture 'MAX' makes remaining patterns unreachable
        print("max")
    case _:
        print("other")

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A bare name as a constant

Use a variable name in a case, expecting a comparison.

main.pywhole filepython
MAX = 10

match 5:
    case MAX:
        print("max")
    case _:
        print("other")
terminal
$ python main.py
── what you'll see ──
File "main.py", line 4
case MAX:
^^^
SyntaxError: name capture 'MAX' makes remaining patterns unreachable

Myth vs fact

Myth

match is just a switch statement.

Fact

A switch compares one value with constants. match takes data apart by shape and binds names in the same step.

Myth

A mapping pattern requires the dict to have exactly those keys.

Fact

Extra keys are fine. Use **rest to capture them or a guard to reject them.

Myth

Sequence patterns match strings.

Fact

Strings, bytes and bytearrays are deliberately excluded from sequence patterns, so [a, b] never matches "hi".

Myth

assert_never checks the cases at run time.

Fact

It is for the checker. At run time it only raises if it is reached.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Class patterns call isinstance and then read attributes with getattr. Matching is not magic: a property that raises, or an attribute that does I/O, will run. Keep patterned classes simple (dataclasses, enums, named tuples).

  • ▸

    Details interviewers probe. A mapping pattern matches any collections.abc.Mapping and looks keys up with .get-style access that does not call __missing__, so matching a defaultdict never inserts keys. A sequence pattern matches any collections.abc.Sequence except str, bytes and bytearray (a NamedTuple matches (a, b)). case int(): also matches True, because bool is a subclass of int: put case bool(): first when it matters. Built-in types such as int(x) and str(text) take one positional sub-pattern that matches the whole value.

  • ▸

    Literal patterns compile to quick comparisons, but a long chain of class patterns is tried one after another. For very hot code with many alternatives, dispatching on a tag with a dictionary of functions can be faster; measure before changing.

  • ▸

    For tagged JSON, mapping patterns pair naturally with TypedDict unions (Topic 10.5): the checker narrows the TypedDict after you match the type literal. Pydantic's discriminated unions do the same with validation.

  • ▸

    Exhaustiveness is the real prize of match with assert_never: when a new variant is added to a union, the checker finds every place that needs updating. Typed languages call this a sealed or algebraic data type; this is the Python way.

Remember this

  1. 1

    You met match with simple values in Topic 2.8. This topic is about structural patterns. A class pattern Point(x=0, y=0) matches an instance of Point (one object made from the class, Topic 8.1) whose attributes (the variables stored on the object) equal those values. The names inside the brackets can capture instead: Circle(radius=r) matches any circle and binds its radius to the new variable r.

  2. 2

    Positional class patterns, like Point(x, y), need to know the order of the fields. Dataclasses (Topic 8.8) and NamedTuples (Topic 10.5) provide it automatically through **__match_args__**, a tuple of names (Point.__match_args__ is ('x', 'y')). Your own plain classes can define __match_args__ = ("first", "second") to allow the same.

  3. 3

    Patterns nest to any depth: Circle(Point(x, y), r) checks the circle, then that its centre is a Point, and binds all three names. A pattern with as captures the whole value too: case Rect(...) as rect. Alternatives use |: "user" | "assistant".

  4. 4

    A guard adds a condition after the pattern: case Circle(c, r) if r > 10 matches only when the pattern fits and r > 10 is true.

  5. 5

    Dictionaries and lists match by shape. A mapping pattern {"type": "tool_call", "name": name} matches any dict with those keys and binds name. Extra keys are allowed (unlike a list or a class pattern), and **rest captures them.

  6. 6

    A sequence pattern [first, *others] matches any list or tuple (but not a string). Use str(text) or int(n) as a class pattern to check the type of a captured value.

  7. 7

    Cases are tried top to bottom: the first match wins. Put specific patterns before general ones; case _: is the catch-all and, as in the example, can stay silent for "ignore". A name by itself (like x) always matches and binds, so never write a bare variable name when you mean to compare with a constant; use a dotted name (Color.RED) or a literal.

  8. 8

    Exhaustiveness: when you match over a union of classes, a final case _: assert_never(e) (Python 3.11, in typing) makes the checker complain if you forgot a member. Add a new class to the union and every match that does not cover it becomes an error in the checker, instead of a silent bug later. At run time, assert_never raises AssertionError if somehow reached.

Explain it without notes

01

How do positional class patterns know which attribute is which?

02

What is the difference between a bare name and a dotted name in a case?

03

How does assert_never give you exhaustiveness?

04

Why do mapping patterns suit JSON events?

05

What does kind(True) return for a match that tests case int(): before case bool():, and why?

Practice

01

Write area(shape) for the dataclasses Square(side) and Rect(w, h) using match, returning the area, and 0 for anything else.

02

Write kind(value) that returns "empty" for an empty list, "one" for a one-item list and "many" for the rest, using sequence patterns.

03

Write command(text) that splits a string and matches ["go", direction] returning "moving north" for go north, and "unknown" otherwise.

Trade-offs

  • ↔

    Clarity versus cleverness. A well-ordered match is easier to read than a chain of isinstance and if. A dense nested pattern with guards can be harder than it looks; split the logic when it grows.

  • ↔

    Exhaustiveness costs updates. Each new union member touches every match, which is exactly the point, but plan for it.

  • ↔

    Python 3.10 and later only. Libraries that support older versions cannot use match.

Done when you can

  • Done when you can write class patterns with keyword and positional arguments.

  • Done when you can explain __match_args__ and give your class one.

  • Done when you can match a dictionary and a list with mapping and sequence patterns.

  • Done when you can use as, | and guards.

  • Done when you can make a match exhaustive with assert_never.

  • Done when you can say why a bare name in a case is a bug.