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
rinCircle(radius=r). - Guard
- An
ifcondition after a pattern that must also be true. - `__match_args__`
- A tuple of attribute names that tells
matchthe 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.
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.
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
Add a shape
In the first example, add a
Squaredataclass withsideand a caseSquare(side=s)returning its area. Put it beforecase _. What would happen if you put it after? - 2
Forget a union member
In the second example, add
class Mulwithaandb, change the union toAdd | Neg | Mul, and runevaluate(Mul(2, 3)). You get theAssertionError. With mypy installed, the checker would have reported the missing case before the run.
Code & diagrams
Dataclasses get __match_args__ for free. The Pair class defines its own. Cases are tried in order.
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')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.
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)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.
MAX = 10
match 5:
case MAX:
print("max")
case _:
print("other")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
isinstanceand then read attributes withgetattr. 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.Mappingand looks keys up with.get-style access that does not call__missing__, so matching adefaultdictnever inserts keys. A sequence pattern matches anycollections.abc.Sequenceexceptstr,bytesandbytearray(a NamedTuple matches(a, b)).case int():also matchesTrue, becauseboolis a subclass ofint: putcase bool():first when it matters. Built-in types such asint(x)andstr(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
TypedDictunions (Topic 10.5): the checker narrows the TypedDict after you match thetypeliteral. Pydantic's discriminated unions do the same with validation. - ▸
Exhaustiveness is the real prize of
matchwithassert_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
You met
matchwith simple values in Topic 2.8. This topic is about structural patterns. A class patternPoint(x=0, y=0)matches an instance ofPoint(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 variabler. - 2
Positional class patterns, like
Point(x, y), need to know the order of the fields. Dataclasses (Topic 8.8) andNamedTuples (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
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 withascaptures the whole value too:case Rect(...) as rect. Alternatives use|:"user" | "assistant". - 4
A guard adds a condition after the pattern:
case Circle(c, r) if r > 10matches only when the pattern fits andr > 10is true. - 5
Dictionaries and lists match by shape. A mapping pattern
{"type": "tool_call", "name": name}matches any dict with those keys and bindsname. Extra keys are allowed (unlike a list or a class pattern), and**restcaptures them. - 6
A sequence pattern
[first, *others]matches any list or tuple (but not a string). Usestr(text)orint(n)as a class pattern to check the type of a captured value. - 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 (likex) 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
Exhaustiveness: when you match over a union of classes, a final
case _: assert_never(e)(Python 3.11, intyping) makes the checker complain if you forgot a member. Add a new class to the union and everymatchthat does not cover it becomes an error in the checker, instead of a silent bug later. At run time,assert_neverraisesAssertionErrorif somehow reached.
Explain it without notes
How do positional class patterns know which attribute is which?
What is the difference between a bare name and a dotted name in a case?
How does assert_never give you exhaustiveness?
Why do mapping patterns suit JSON events?
What does kind(True) return for a match that tests case int(): before case bool():, and why?
Practice
Write area(shape) for the dataclasses Square(side) and Rect(w, h) using match, returning the area, and 0 for anything else.
Write kind(value) that returns "empty" for an empty list, "one" for a one-item list and "many" for the rest, using sequence patterns.
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
isinstanceandif. 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
matchexhaustive withassert_never.Done when you can say why a bare name in a case is a bug.