Structural pattern matching in depth
Destructure dictionaries, nested sequences and your own classes with match, and avoid the patterns that silently match everything.
What you will learn
- Match dictionaries with mapping patterns, including **rest
- Check types and capture values with class patterns for built-in types
- Destructure dataclasses and your own classes, positionally through __match_args__ or by keyword
- Tell value patterns from capture patterns, and use guards and as to refine a match
On this page
The match statements intro covered literals, wildcards, guards and sequence patterns, which is enough for a command parser. Real data is usually nested: JSON that arrives as dictionaries inside lists, or objects that hold other objects. This lesson covers the patterns that take such data apart in one step: mapping patterns, class patterns, and the rules for dotted names. Everything here needs Python 3.10 or later.
Before you start: you should be comfortable with that beginner lesson, with dataclasses, and with narrowing, which shows how a type checker follows each case and checks that a match is exhaustive.
Mapping patterns
A pattern written with braces matches any mapping, such as a dict, that has at least the keys it lists. Each value in the pattern is itself a pattern, so it can be a literal to compare or a name to capture:
events = [ {"type": "click", "x": 10, "y": 20}, {"type": "key", "key": "q", "shift": True}, {"type": "scroll", "dy": -3, "source": "wheel"}, {"type": "click"},]
for event in events: match event: case {"type": "click", "x": x, "y": y}: print(f"click at ({x}, {y})") case {"type": "key", "key": key}: print(f"key {key!r}") case {"type": kind, **rest}: print(f"unhandled {kind}: {rest}")click at (10, 20)key 'q'unhandled scroll: {'dy': -3, 'source': 'wheel'}unhandled click: {}Three rules show up here. Extra keys do not prevent a match: the key event also carries "shift", and it still matched. A missing key does: the last click has no x, so the first case failed and the event fell through to the general one. And **rest collects the keys that the pattern did not name into a new dictionary, which is empty when nothing is left over.
A mapping pattern never matches a list or a string, and it looks keys up with get, so a defaultdict does not invent missing keys during a match.
Class patterns for built-in types
A pattern such as int(n) means “an int, and call it n”. It checks the type with isinstance and captures the value. str() with nothing inside only checks the type:
def describe(value): match value: case bool(flag): return f"flag {flag}" case int(n) | float(n): return f"number {n}" case str() as text if text.isdigit(): return f"digits {text}" case str(text): return f"text {text!r}" case [int(), int()] as pair: return f"int pair {pair}" case [*items]: return f"sequence of {len(items)}" case _: return f"other {type(value).__name__}"
for value in [True, 3, 2.5, "42", "hi", [1, 2], (1, "a", None), {"a": 1}]: print(describe(value))flag Truenumber 3number 2.5digits 42text 'hi'int pair [1, 2]sequence of 3other dictRead the cases in order, as Python does:
bool(flag)comes first becauseTrueis also anint. Swapped round,Truewould printnumber True.int(n) | float(n)is an alternative. Both sides must bind the same names.str() as textchecks the type and binds the whole value withas, and the guard then tests it.[int(), int()] as paircombines a sequence pattern with type checks on each item.[*items]matched the tuple. Sequence patterns accept any sequence exceptstr,bytesandbytearray, which is why"hi"never reached it.
Class patterns for your own classes
The same syntax works with any class. A dataclass is the easiest case, because it generates a __match_args__ tuple that lists the fields in order. That tuple is what lets you match positionally:
from dataclasses import dataclass
@dataclassclass Point: x: float y: float
@dataclassclass Circle: centre: Point radius: float
@dataclassclass Rect: corner: Point width: float height: float
print(Point.__match_args__, Rect.__match_args__)('x', 'y') ('corner', 'width', 'height')Now patterns can nest one object inside another. Positional sub-patterns follow __match_args__, and keyword sub-patterns name attributes directly:
def describe(shape): match shape: case Circle(Point(0, 0), r): return f"circle of radius {r} at the origin" case Circle(centre=Point(x=x), radius=r) if r > 5: return f"big circle, centre x={x}" case Rect(_, w, h) if w == h: return f"square, side {w}" case Rect(width=w, height=h): return f"rectangle {w}x{h}" case Point() | Circle(): return f"other {type(shape).__name__}"
shapes = [ Circle(Point(0, 0), 2), Circle(Point(3, 4), 9), Rect(Point(1, 1), 4, 4), Rect(Point(0, 0), 2, 5), Circle(Point(1, 1), 1),]for shape in shapes: print(describe(shape))circle of radius 2 at the originbig circle, centre x=3square, side 4rectangle 2x5other CircleCircle(Point(0, 0), r) checks that the shape is a Circle, that its first field is a Point whose fields equal 0 and 0, and captures the radius. Point(x=x) names only the attribute it cares about. The guards then compare captured values, and Point() | Circle() is a pure type check. If no case matches, the function falls off the end and returns None, the same as without match.
An ordinary class has no __match_args__, so keyword patterns work but positional ones raise an error when they are tried:
class Reading: def __init__(self, sensor, value): self.sensor = sensor self.value = value
reading = Reading("temp", 21.5)
match reading: case Reading(sensor="temp", value=v): print("keyword pattern:", v)
try: match reading: case Reading("temp", v): print("positional pattern:", v)except TypeError as error: print("TypeError:", error)keyword pattern: 21.5TypeError: Reading() accepts 0 positional sub-patterns (2 given)Adding the class attribute fixes it. List the names of the attributes in the order you want positional patterns to use:
class Reading: __match_args__ = ("sensor", "value")
def __init__(self, sensor, value): self.sensor = sensor self.value = value
match Reading("temp", 21.5): case Reading("temp", v): print("positional pattern:", v)positional pattern: 21.5Value patterns need a dot
The beginner lesson’s rule still applies: a dotted name such as Level.ERROR is a value pattern, compared with ==, and a bare name always captures. That makes enums the natural home for constants you match on:
from enum import Enum
class Level(Enum): INFO = 1 WARNING = 2 ERROR = 3
def route(level): match level: case Level.ERROR | Level.WARNING: return "pager" case Level.INFO: return "log file"
print(route(Level.WARNING), route(Level.INFO))pager log fileWhen not to use match
match earns its place when the shape of the data decides what to do: nested dictionaries from JSON, a tree of dataclasses, a tuple of tokens. When you only compare one value against constants, a dictionary lookup or an if chain is shorter. When the behaviour depends on the type of an object you own, a method on each class (polymorphism) keeps the logic next to the data and lets new classes join without editing a central match. And for validating external data in depth, a schema library or a TypedDict checked by a type checker documents the shape better than a long list of mapping patterns.
Common pitfalls
- Expecting a mapping pattern to require exact keys.
{"type": "click"}matches any dictionary that has that key. Add**restand a guard such asif not restwhen extra keys are an error. - Putting
int(n)beforebool(b).TrueandFalseare integers, so the order of cases decides which one wins. - Expecting a string to match a sequence pattern.
[a, b]never matches"ab". Usestr(s)and then work on the string. - Positional patterns on a class without
__match_args__. They raiseTypeErrorwhen tried. Use keywords, or add the attribute. - Bare names as constants.
case ERROR:captures and matches anything. Use a dotted name such asLevel.ERROR. - Alternatives that bind different names.
case {"x": x} | {"y": y}:is aSyntaxError, because each side of|must bind the same names. Bind the same name on both sides, or split the case.
Write route(event), which turns each event below into a line of text, using one match statement. A purchase reports its number of item lines and its first item. Expected output:
ada logged inada bought 1 line(s), first: A1 x2bob sent an empty purchaseada logged out (timeout)bob logged outunknown event: pingimport json
events = json.loads("""[ {"event": "login", "user": "ada"}, {"event": "purchase", "user": "ada", "items": [{"sku": "A1", "qty": 2}]}, {"event": "purchase", "user": "bob", "items": []}, {"event": "logout", "user": "ada", "reason": "timeout"}, {"event": "logout", "user": "bob"}, {"event": "ping"}]""")Show solution
import json
events = json.loads("""[ {"event": "login", "user": "ada"}, {"event": "purchase", "user": "ada", "items": [{"sku": "A1", "qty": 2}]}, {"event": "purchase", "user": "bob", "items": []}, {"event": "logout", "user": "ada", "reason": "timeout"}, {"event": "logout", "user": "bob"}, {"event": "ping"}]""")def route(event): match event: case {"event": "login", "user": str(user)}: return f"{user} logged in" case {"event": "purchase", "user": user, "items": []}: return f"{user} sent an empty purchase" case {"event": "purchase", "user": user, "items": [{"sku": sku, "qty": int(qty)}, *rest]}: lines = 1 + len(rest) return f"{user} bought {lines} line(s), first: {sku} x{qty}" case {"event": "logout", "user": user, "reason": reason}: return f"{user} logged out ({reason})" case {"event": "logout", "user": user}: return f"{user} logged out" case {"event": kind}: return f"unknown event: {kind}"
for event in events: print(route(event))ada logged inada bought 1 line(s), first: A1 x2bob sent an empty purchaseada logged out (timeout)bob logged outunknown event: pingThe logout with a reason must come before the one without, because the shorter mapping pattern would match both. The two purchase cases cannot overlap, since [{"sku": sku, "qty": int(qty)}, *rest] needs at least one item, so their order is a matter of reading the special case first.
What does this print? Decide for each call before you run it.
def check(value): match value: case [x, y]: return f"pair {x} {y}" case {"id": n}: return f"id {n}" case str(s): return f"text {s}" return "no match"
print(check("ab"))print(check({"id": 1, "x": 2}))print(check((1, 2)))print(check({"x": 1}))Show solution
text abid 1pair 1 2no match"ab" is a sequence of two characters, but sequence patterns skip strings, so it reaches str(s). The dictionary with an extra key still matches {"id": n}. The tuple matches [x, y], since square brackets in a pattern accept any sequence. {"x": 1} has no "id" key and is not a string, so nothing matches.
This script prints a TypeError for every amount. Fix the class, without changing show or the loop, so that it prints nothing, 5 euro and 7 GBP.
class Money: def __init__(self, amount, currency): self.amount = amount self.currency = currency
def show(money): match money: case Money(0, _): return "nothing" case Money(amount, "EUR"): return f"{amount} euro" case Money(amount, currency): return f"{amount} {currency}"
for money in [Money(0, "USD"), Money(5, "EUR"), Money(7, "GBP")]: try: print(show(money)) except TypeError as error: print("TypeError:", error)TypeError: Money() accepts 0 positional sub-patterns (2 given)TypeError: Money() accepts 0 positional sub-patterns (2 given)TypeError: Money() accepts 0 positional sub-patterns (2 given)Show solution
class Money: __match_args__ = ("amount", "currency")
def __init__(self, amount, currency): self.amount = amount self.currency = currency
def show(money): match money: case Money(0, _): return "nothing" case Money(amount, "EUR"): return f"{amount} euro" case Money(amount, currency): return f"{amount} {currency}"
for money in [Money(0, "USD"), Money(5, "EUR"), Money(7, "GBP")]: try: print(show(money)) except TypeError as error: print("TypeError:", error)nothing5 euro7 GBPMoney is an ordinary class, so it has no __match_args__, and Money(0, _) has no way to know which attribute comes first. The tuple maps the first positional sub-pattern to amount and the second to currency.
Summary
- Mapping patterns match any mapping with at least the listed keys.
**restcaptures the others. int(n),str()and friends check a type and can capture the value.asbinds a whole sub-pattern.- Class patterns match dataclasses positionally through the generated
__match_args__, and any class by keyword. Add__match_args__to your own classes to allow positional patterns. - A dotted name is compared. A bare name captures, and matches anything.
- Next: the collections module, with containers made for counting, grouping and queueing.