Python 3.10 Gets match and case: A Working Guide to Structural Pattern Matching
Python 3.10.0 was released on Monday, October 4, and the headline feature is one people have argued about for years: a match statement. It’s specified across three PEPs, which is a hint about how much thought went into it. PEP 634 is the spec, PEP 635 is the motivation and design rationale, and PEP 636 is a tutorial. If you only read one, read the tutorial, then keep the spec open for the edge cases.
The quick summary is that it looks like a switch statement and mostly isn’t one. The authors say so pretty directly in PEP 635: pattern matching is built as a generalization of iterable unpacking, the thing that lets you write host, port = pair, and pulling values out of a structure is the main use case. Comparing a value to a list of constants is a side benefit. Keeping that framing in mind makes the design choices, including the confusing one, make a lot more sense.
The shape of a match
A match takes a subject expression and tries each case from top to bottom. The first pattern that matches (and whose guard, if it has one, is truthy) wins, its block runs, and nothing after it is tried. There’s no fall through, so there’s no break to forget. If nothing matches, the statement just does nothing, which is worth remembering if you’re used to languages that force exhaustiveness.
Here’s a handler for event dictionaries, the kind of thing you’d get from json.loads on a webhook body:
def handle(event):
match event:
case {"action": "opened", "number": int(number)}:
return f"new ticket #{number}"
case {"action": "closed", "number": int(number), "reason": str(reason)}:
return f"#{number} closed: {reason}"
case {"action": "labeled", "labels": [first, *rest]}:
return f"labeled {first} plus {len(rest)} more"
case {"action": str(other)}:
return f"ignoring {other}"
case _:
raise ValueError(f"not an event: {event!r}")
That one block uses most of the pattern types. The string literals like "opened" are literal patterns, compared with ==. The one exception is None, True and False, which are compared with is, so case True: won’t match 1. Bare names like number and reason are capture patterns: they always succeed and bind whatever is in that position. The _ at the bottom is the wildcard, which also always succeeds but binds nothing.
The curly brace patterns are mapping patterns. They check that the subject is a mapping, that every listed key is present, and that each value matches its own subpattern. Extra keys in the subject are ignored, so an event with a dozen other fields still matches. If you want the leftovers, **rest collects them.
[first, *rest] is a sequence pattern. It works like extended unpacking: this one needs at least one element, binds the first and puts the rest in a list. A pattern without a star needs an exact length. Square brackets and parentheses mean the same thing here, and both match any sequence, lists and tuples alike. Two exceptions are deliberate: sequence patterns never match strings, bytes or bytearrays (so "hi" won’t quietly unpack into characters), and they don’t match iterators either.
int(number) and str(reason) are class patterns. For a handful of builtins, including int, str, float, bool, list and dict, a single positional argument matches the whole subject. So int(number) means “this is an int, and call it number.” That makes the handler above validate types as it destructures. If the JSON had "number": "42" as a string, the first case fails and the event falls through to ignoring opened, instead of a string sneaking into code that expects an int.
Classes, positions and guards
Class patterns get more useful with your own types. Point(x=0, y=y) checks isinstance and then matches attributes by keyword. Positional arguments work when the class tells Python what order to use, through a __match_args__ class attribute. Dataclasses and named tuples generate it for you in 3.10, in the same order as the constructor arguments.
from dataclasses import dataclass
@dataclass
class Retry:
attempts: int
delay: float
@dataclass
class Failed:
reason: str
def next_step(result):
match result:
case Retry(attempts, delay) if attempts >= 5:
return "give up"
case Retry(attempts, delay=0):
return f"retry now (attempt {attempts + 1})"
case Retry(attempts, delay):
return f"retry in {delay}s"
case Failed(reason="timeout"):
return "page someone"
case Failed():
return "log it"
The if attempts >= 5 part is a guard. It isn’t part of the pattern; it runs only after the pattern has matched and bound its names, which is why it can use attempts. If the guard is falsy, matching moves on to the next case. Failed() with no arguments is just an isinstance check.
A couple of other pieces round things out. | builds an or pattern, so case 500 | 502 | 503: works, with the rule that every alternative has to bind the same names. And as lets you match a subpattern and keep the whole thing, as in case [Retry() as first, *_]:.
The gotcha: bare names capture, they don’t compare
This is the one that will bite someone on your team. Suppose you write:
NOT_FOUND = 404
def describe(status):
match status:
case NOT_FOUND:
return "not found"
return "something else"
That doesn’t compare status to 404. NOT_FOUND in a case is a capture pattern, so it matches anything and assigns the subject to a local variable called NOT_FOUND. describe(200) returns "not found". Put any other case after it and Python refuses to compile the function with a SyntaxError, because PEP 634 allows only one case that can never fail and requires it to be last. When it’s the final case, though, nothing warns you.
The fix is to use a dotted name. Anything with a dot in it is a value pattern, looked up normally and compared with ==:
from http import HTTPStatus
def classify(status):
match status:
case HTTPStatus.OK | HTTPStatus.CREATED:
return "fine"
case HTTPStatus.NOT_FOUND:
return "missing"
case _:
return "something else"
Since HTTPStatus is an IntEnum, that matches plain 404 too. PEP 635 explains why it ended up this way. The authors considered treating uppercase names as constants, a leading dot, and sigils like $CONSTANT or ^CONSTANT, and didn’t adopt any of them, leaving a constant marker for a possible future PEP. The practical rule from the tutorial is simple enough: keep constants in an enum or a module or class namespace and always refer to them with a dot. If a constant really has to be a plain name, compare it in a guard instead.
Two smaller things from the spec are worth knowing. Names bound by a successful match stick around after the match statement, just like variables from a for loop. And if a pattern fails partway through, some of its names may or may not have been bound already; PEP 634 leaves that intentionally unspecified, so don’t write code that depends on either outcome.
When it beats if/elif
I wouldn’t rewrite every if chain. For “is this value one of three strings,” if/elif or a dict lookup is fine and reads just as well. match pulls ahead when you branch on shape and then immediately unpack what you found. PEP 635 starts its motivation section with exactly that situation: chains of isinstance, len(x) == n and "key" in x checks followed by manual indexing. With match, the check and the unpacking are the same line, so they can’t drift apart when someone edits one and forgets the other.
The places I’d reach for it first are parsing loosely typed input like decoded JSON, command line style argument lists, message or event dispatch across a few dataclasses, and tree walking code like AST visitors, where a nested class pattern can replace a paragraph of attribute checks. The match statement section of the official tutorial is a good short reference to keep handy.
One nice detail for existing code: match and case are soft keywords. They only act as keywords at the start of a match statement or case block, so your re.match(...) calls and variables named match keep working.
The rest of 3.10 that you’ll actually notice
The What’s New in Python 3.10 page covers a lot, but two changes will reach day to day work fastest.
Error messages got a lot better. An unclosed bracket now gets reported at the bracket itself with a message saying it was never closed, instead of a vague invalid syntax pointing at some later line. A missing colon before a block says expected ':', a missing comma in a collection literal suggests you may have forgotten one, and the error range for a bad expression is highlighted in full rather than marked at a single character. NameError and AttributeError now suggest similar names when you’ve got a typo. One caveat from the docs: those suggestions come from the default traceback display, so REPLs with their own error display, IPython being the common one, may not show them.
On the typing side, PEP 604 lets you write unions as X | Y:
def parse_port(value: str | int) -> int | None:
...
isinstance(8080, int | str) # True
int | None replaces Optional[int], and the same syntax works as the second argument to isinstance() and issubclass(). Annotations are still evaluated when the function is defined by default (the release notes say the planned switch to postponed evaluation was pushed to 3.11), so a signature like the one above won’t import on 3.9, and the isinstance form needs 3.10 no matter what. Libraries that support older versions should hold off for now.
There’s more in the release worth a look (parenthesized context managers, a strict flag on zip(), and an opt in EncodingWarning for open() calls that rely on the locale encoding), but pattern matching is the change that will reshape code. My suggestion is to try it first on a parser or a message dispatcher where you’re already writing isinstance chains, get everyone used to dotted constants from day one, and let it spread from there.