Pattern matching
match and case: literal, sequence, mapping and class patterns with guards.
Create a free account Try part of it here
Why it matters
Programs constantly look at a piece of data and decide what to do based on its shape: an API event with a "type" field, a command a user typed, a message from a model with different kinds of content. Python's match statement (since 3.10) checks the shape and pulls out the parts in one step. Chains of if, isinstance and dictionary lookups become a readable table of cases.
What you'll be able to do
- Write match statements with literal, capture and wildcard patterns
- Match the shape of lists and dictionaries, and pull values out of them in one step
- Match objects by class and attributes, and add conditions with guards
- Know when match makes code clearer, and when a plain if is better
What it covers
- match and case
- Matching the shape of a list
- Matching dictionaries: events and API messages
- Matching objects
- Shipping rules by shape
- When to use match, and when not to
- Comparing with constants: enums
- Worked example: a tiny command interpreter for the shop's stockroom
match and case
A match statement compares one value, the subject, against a list of case patterns, top to bottom. The first pattern that fits wins, and only its block runs:
def describe(status): match status: case 200 | 201: return "success" case 404: return "not found" case 429 | 503: return "busy: try again later" case code if 500 <= code < 600: return f"server error {code}" case _: return "something else" for code in [200, 201, 404, 503, 502, 302]: print(code, describe(code))
- A literal pattern like
404matches that exact value.|means "or". - A bare name like
codeis a capture pattern: it matches anything and stores it under that name. if ...after a pattern is a guard: the case only matches if the condition is also true._is the wildcard: it matches anything and stores nothing, so it works as the "otherwise" case.
If no case matches and there's no _, nothing happens: match doesn't raise an error, so a missing case can slip through quietly. A final case _: that raises or logs makes the gap visible.
Matching the shape of a list
Sequence patterns match lists and tuples by their length and contents, and capture the parts. This suits text commands, which split into a list of words:
def run(command): match command.split(): case ["help"]: return "commands: add SKU [QTY], remove SKU, list, quit" case ["add", sku]: return f"added 1 x {sku}" case ["add", sku, qty] if qty.isdigit(): return f"added {qty} x {sku}" case ["remove", sku]: return f"removed {sku}" case ["list" | "ls"]: return "listing the cart" case ["quit" | "exit", *_]: return "bye" case [verb, *rest]: return f"unknown command {verb!r} with {len(rest)} argument(s)" case []: return "type a command" for line in ["help", "add TE-101", "add PA-108 3", "add PA-108 three", "ls", "exit now", "buy it", ""]: print(repr(line), "->", run(line))
["add", sku] matches a two-word list whose first word is "add", and captures the second. *rest captures any number of remaining items, like *args in a function. Notice "add PA-108 three" fails the guard on the three-word case and falls through to the general [verb, *rest] case, so order the cases from most specific to most general.
Step through the second call: the three-word case's shape fits but its guard fails, so matching moves on to the general case.
def f(x): match x: case [a, b]: return a + b case [a, *rest]: return len(rest) case _: return -1 print(f([1, 2]), f([1, 2, 3]), f("no"))
Show the answer
3 2 -1
[1, 2] fits [a, b]. [1, 2, 3] has three items, so it falls to [a, *rest] with two left over. A string never matches a sequence pattern in match, so "no" hits the wildcard.
Matching dictionaries: events and API messages
Mapping patterns match dictionaries by the keys they name, and ignore any other keys. That's exactly how events from APIs and webhooks look: a type field says which kind, and each kind has its own fields.
def handle(event): match event: case {"type": "order.created", "order_id": oid, "total": total} if total >= 500: return f"big order {oid}: notify the manager" case {"type": "order.created", "order_id": oid}: return f"new order {oid}" case {"type": "order.refunded", "order_id": oid, "reason": str(reason)}: return f"refund on {oid}: {reason}" case {"type": "stock.low", "items": [first, *more]}: return f"low stock: {first} and {len(more)} more" case {"type": str(kind)}: return f"ignored event {kind}" case _: raise ValueError(f"not an event: {event!r}") events = [ {"type": "order.created", "order_id": 50231, "total": 1240, "store": "online"}, {"type": "order.created", "order_id": 50232, "total": 60}, {"type": "order.refunded", "order_id": 50101, "reason": "arrived damaged"}, {"type": "stock.low", "items": ["TE-101", "PA-108", "FO-120"]}, {"type": "customer.updated", "id": 9}, ] for e in events: print(handle(e))
{"type": "order.created", "order_id": oid} matches any dictionary whose "type" is that string and which has an "order_id" key, capturing its value; extra keys like "store" are ignored. str(reason) matches only if the value is a string (it's a class pattern, covered next). Patterns nest, so "items": [first, *more] checks the list inside the dictionary too.
Matching objects
A class pattern like Point(x=0) matches objects of that class, and checks or captures their attributes. Dataclasses work with it straight away:
from dataclasses import dataclass @dataclass class Text: body: str @dataclass class Image: url: str width: int @dataclass class ToolCall: name: str args: dict def render(block): match block: case Text(body=""): return "(empty text)" case Text(body=body): return body case Image(width=w, url=url) if w > 1000: return f"[large image {url}, will be resized]" case Image(url=url): return f"[image {url}]" case ToolCall(name="get_weather", args={"trailhead": place}): return f"(checking the weather at {place})" case ToolCall(name=name): return f"(calling tool {name})" reply = [Text("Here's the forecast:"), ToolCall("get_weather", {"trailhead": "fern-hollow"}), Image("map.png", 2400), Text("")] for block in reply: print(render(block))
This is the shape of code that handles a language model's reply, where each content block is text, an image or a tool call. The class pattern checks the type, like isinstance, and the keyword arguments check or capture attributes, all in one line. Built-in types work as patterns too: int(n), str(s), list().
Shipping rules by shape
Write fee(order) with a match on the order dictionary: orders with "member": True ship free; otherwise "dest": "domestic" costs 6.0 if "total" is under 50 and 0.0 otherwise; "dest": "international" costs 25.0; anything else raises ValueError.
def fee(order): pass print(fee({"dest": "domestic", "total": 30}), fee({"dest": "domestic", "total": 80, "member": True}))
When to use match, and when not to
match shines when you're branching on the shape of data and pulling pieces out of it: events with a type field, commands, parsed messages, tree structures. One case replaces an isinstance check, a few key lookups and the variable assignments that would follow them.
For simple comparisons it adds nothing. if price > 100: is clearer than a match with a guard. A dictionary from keys to functions (from the functions-as-values lesson) is often better when every case just calls a different handler.
Two traps catch newcomers:
- A bare name always captures.
case MAX_QTY:doesn't compare with a constant calledMAX_QTY; it matches anything and rebinds the name. To compare with a constant, use a dotted name likecase Limits.MAX_QTY:or an enum member likecase Color.RED:. - Strings aren't sequences in patterns.
case [first, *rest]:doesn't match the string "add", even though a string is a sequence elsewhere in Python. Split it into a list first.
Finally, remember that match checks shape, not truth. A mapping pattern only proves the keys exist: a "total" of "lots" or None still matches {"total": total}. Where the type of a value matters, say so in the pattern with int(total) or str(name), or check it with a guard, especially for data that came from outside the program, such as a webhook or a file.
Comparing with constants: enums
The bare-name trap has a clean fix. An enum gives a fixed set of named values, and because its members are written with a dot (Status.SHIPPED), match compares with them instead of capturing:
from enum import Enum class Status(Enum): PLACED = "placed" SHIPPED = "shipped" DELIVERED = "delivered" RETURNED = "returned" def next_step(status, days_since): match status: case Status.PLACED if days_since > 2: return "chase the warehouse" case Status.PLACED: return "wait" case Status.SHIPPED | Status.DELIVERED: return "nothing to do" case Status.RETURNED: return "refund" for status, days in [(Status.PLACED, 1), (Status.PLACED, 4), (Status("delivered"), 9), (Status.RETURNED, 0)]: print(status.name, "->", next_step(status, days))
Status("delivered") turns a string from a database or an API into the enum member, and raises ValueError for anything that isn't a real status. That catches bad data at the edge of the program, instead of letting a typo like "delivred" quietly match nothing deep inside it. Enums and match work well together: every possible value has a name, and each case reads as one rule of the business.
Worked example: a tiny command interpreter for the shop's stockroom
Parse typed commands into actions on a stock dictionary, with helpful errors for anything unexpected.
stock = {"TE-101": 4, "PA-108": 0}
def execute(line):
match line.strip().lower().split():
case ["stock", sku]:
return f"{sku.upper()}: {stock.get(sku.upper(), 'unknown')}"
case ["receive", sku, qty] if qty.isdigit() and int(qty) > 0:
stock[sku.upper()] = stock.get(sku.upper(), 0) + int(qty)
return f"received {qty} of {sku.upper()}"
case ["receive", *_]:
return "usage: receive SKU QTY (QTY a positive whole number)"
case ["empty"]:
return ", ".join(sku for sku, n in stock.items() if n == 0) or "nothing is out of stock"
case [verb, *_]:
return f"unknown command {verb!r}"
case []:
return "type a command"
for line in ["stock te-101", "receive pa-108 6", "receive pa-108 lots", "empty", "STOCK PA-108", "sell te-101", " "]:
print(f"{line!r:24} -> {execute(line)}")The subject is the cleaned, split line, so every case works with a list of words. The receive case with a guard handles the valid form, and the second receive case catches every malformed version with a usage message, which is friendlier than "unknown command". The general [verb, *_] case and the empty-list case cover the rest, so every input gets an answer. Each case reads as one rule of the stockroom's little language.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does case LIMIT: do when LIMIT is a module-level constant?