Python match-case Statement
The match statement, introduced in Python 3.10, is Python’s answer to the classic switch statement found in languages like C, Java, or JavaScript — but it is considerably more powerful. Instead of only comparing a value against a fixed list of options, match performs structural pattern matching: it can look inside tuples, lists, dictionaries, and objects, pull values out of them, and branch based on their shape as well as their content. This makes it possible to replace long, brittle if/elif/isinstance chains with code that reads almost like a description of the data itself.
Overview / How it works
A match statement takes a subject — the value you want to inspect — and compares it, top to bottom, against a series of case clauses. Each case holds a pattern. Python checks the patterns in the order they are written and executes the body of the first one that matches. Unlike a C-style switch, there is no “fall-through”: once a case’s body runs, execution jumps straight to the code after the whole match block, and if no pattern matches, nothing happens at all (no error is raised) unless you supply a wildcard case.
What makes match different from a chain of equality checks is that patterns can destructure the subject. A sequence pattern like case [x, y]: doesn’t just check that the subject equals something — it checks that the subject is a sequence of exactly two items, and if so, binds those items to the names x and y right there in the pattern. A class pattern like case Point(x=0, y=0): checks that the subject is an instance of Point and that its x and y attributes equal zero. This combination of type-checking, shape-checking, and value-extraction in a single readable line is the whole point of structural pattern matching.
Under the hood, each kind of pattern maps to a specific kind of check: literal patterns (200, "GET", None) use equality comparison; capture patterns (a bare lowercase name) always succeed and bind the subject to that name; sequence and mapping patterns check the type and length/keys before destructuring; class patterns call isinstance() and then match attributes either positionally (using the class’s __match_args__) or by keyword. Because of this, match is not just syntax sugar — it is a genuinely different, declarative way of expressing multi-way branching logic.
Syntax
match subject:
case pattern1:
...
case pattern2 if condition:
...
case pattern3 | pattern4:
...
case _:
...
| Pattern kind | Example | What it does |
|---|---|---|
| Literal pattern | case 404: |
Matches if the subject equals this literal value |
| Capture pattern | case x: |
Always matches; binds the subject to the name x |
| Wildcard pattern | case _: |
Always matches; binds nothing (the default/catch-all case) |
| Sequence pattern | case [a, b, *rest]: |
Matches lists/tuples, destructuring elements (*rest collects extras) |
| Mapping pattern | case {"key": value}: |
Matches dict-like objects containing at least the given keys |
| Class pattern | case Point(x=0, y=y): |
Matches instances of a class, checking/extracting attributes |
| Or pattern | case 400 | 401 | 403: |
Matches if any of the alternatives match |
| Guard clause | case x if x > 0: |
Pattern must match and the condition must be true |
| As pattern | case [x, y] as pair: |
Matches the sub-pattern and also binds the whole subject to pair |
The subject can be any expression — a variable, a function call, a tuple literal. Each case line ends with a colon and an indented block, exactly like if or for.
Examples
Example 1: A simple value-based switch
def http_status_message(code: int) -> str:
match code:
case 200:
return "OK"
case 201:
return "Created"
case 400:
return "Bad Request"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
case _:
return "Unknown Status Code"
for status in [200, 404, 999]:
print(f"{status}: {http_status_message(status)}")
Output:
200: OK
404: Not Found
999: Unknown Status Code
This is the simplest use of match: it behaves like a cleaner if/elif chain of equality checks. The final case _: acts as the default branch, catching any status code that wasn’t listed explicitly.
Example 2: Destructuring sequences with guards
def handle_command(command: list[str]) -> str:
match command:
case ["go", direction]:
return f"Moving {direction}"
case ["go", *rest] if len(rest) > 1:
return "Error: 'go' takes exactly one direction"
case ["look"]:
return "You see nothing special"
case ["take", *items]:
return f"Picking up: {', '.join(items)}"
case []:
return "No command given"
case _:
return "Unknown command"
commands = [
["go", "north"],
["go", "north", "fast"],
["look"],
["take", "sword", "shield"],
[],
["fly"],
]
for cmd in commands:
print(handle_command(cmd))
Output:
Moving north
Error: 'go' takes exactly one direction
You see nothing special
Picking up: sword, shield
No command given
Unknown command
Here the subject is a list, and each pattern both checks its shape and pulls values out of it. ["go", direction] only matches lists of exactly two elements whose first item is the string "go"; the second element is bound to direction. The *rest syntax in a sequence pattern works like starred unpacking anywhere else in Python — it collects any number of remaining elements into a list, which is then available to a guard (if len(rest) > 1) to add extra conditions beyond what the pattern shape alone can express.
Example 3: Matching classes and using guards for relationships
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def describe_point(point: Point) -> str:
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0, y=y):
return f"On the y-axis at y={y}"
case Point(x=x, y=0):
return f"On the x-axis at x={x}"
case Point(x=x, y=y) if x == y:
return f"On the diagonal at ({x}, {y})"
case Point(x=x, y=y):
return f"Point at ({x}, {y})"
case _:
return "Not a point"
points = [Point(0, 0), Point(0, 5), Point(3, 0), Point(4, 4), Point(2, 7)]
for p in points:
print(describe_point(p))
Output:
Origin
On the y-axis at y=5
On the x-axis at x=3
On the diagonal at (4, 4)
Point at (2, 7)
This example shows why match shines with real objects. Each case Point(...) pattern first checks isinstance(point, Point), then matches the given keyword attributes. Because dataclasses automatically generate an __eq__ and store fields as real attributes, they work naturally as match subjects. Note how the cases go from most specific (x=0, y=0) to least specific (a bare Point(x=x, y=y)) — order matters, which leads directly into a very common mistake below.
Under the hood
When Python executes a match statement, it evaluates the subject expression exactly once and then walks the case clauses in source order. For each clause, it attempts to unify the pattern with the subject:
- A literal pattern is compared with
==. - A capture pattern (a bare name) always succeeds; the interpreter binds that name in the enclosing scope, just like an assignment.
- A sequence pattern first checks that the subject supports sequence unpacking (roughly: it’s a list, tuple, or similar — but explicitly not a string or bytes object, to avoid the common bug of a string silently matching as a sequence of characters) and has a compatible length, then recursively matches each element.
- A mapping pattern checks the subject is dict-like and contains at least the specified keys (extra keys in the dict are ignored unless you add
**rest), then recursively matches each value. - A class pattern calls
isinstance(), then matches keyword arguments against attributes viagetattr, or positional arguments via the class’s__match_args__tuple.
If a pattern has a guard (if condition), the guard is only evaluated after the pattern itself has successfully matched and bound its names — so the guard can reference variables the pattern just captured. If the guard is falsy, matching continues to the next case as though this one had failed entirely. As soon as one clause fully succeeds (pattern plus guard), its body runs and the match statement ends — no other clause is even considered.
Common Mistakes
Mistake 1: Bare names are always capture patterns, never value comparisons
A frequent surprise is using a variable holding a constant as a case pattern, expecting Python to compare against its value. It doesn’t — a bare lowercase name in a pattern is always a capture pattern, meaning it matches unconditionally and just binds whatever the subject is.
STATUS_NOT_FOUND = 404
STATUS_OK = 200
def check(status):
match status:
case STATUS_NOT_FOUND:
return "Not Found (?)"
case STATUS_OK:
return "OK"
case _:
return "Unknown"
print(check(200))
print(check(404))
print(check(999))
Output:
Not Found (?)
Not Found (?)
Not Found (?)
Every call returns "Not Found (?)" because case STATUS_NOT_FOUND: is treated as “capture the subject into a new local variable called STATUS_NOT_FOUND” — it always succeeds on the very first case, regardless of the actual status. To compare against a constant’s value, use a guard or a dotted attribute name (dotted names, unlike bare names, are treated as value lookups, not captures):
STATUS_NOT_FOUND = 404
STATUS_OK = 200
def check(status):
match status:
case s if s == STATUS_NOT_FOUND:
return "Not Found"
case s if s == STATUS_OK:
return "OK"
case _:
return "Unknown"
print(check(200))
print(check(404))
print(check(999))
Output:
OK
Not Found
Unknown
Mistake 2: Putting a general pattern before a more specific one
Because case clauses are checked in order and the first match wins, a broad capture pattern placed too early will shadow every more specific pattern written after it.
def describe(point):
match point:
case (x, y):
return f"Point at ({x}, {y})"
case (0, 0):
return "Origin"
case _:
return "Not a point"
print(describe((0, 0)))
print(describe((3, 4)))
Output:
Point at (0, 0)
Point at (3, 4)
The (0, 0) case is unreachable dead code — (x, y) already matches any two-element tuple, including (0, 0), and it comes first. The fix is to always order patterns from most specific to most general:
def describe(point):
match point:
case (0, 0):
return "Origin"
case (x, y):
return f"Point at ({x}, {y})"
case _:
return "Not a point"
print(describe((0, 0)))
print(describe((3, 4)))
Output:
Origin
Point at (3, 4)
Best Practices
- Order
caseclauses from most specific to most general; put the catch-allcase _:last — a case after it can never run. - Always include a
case _:when you need guaranteed behavior for unmatched input; without it, an unmatched subject silently does nothing, which can hide bugs. - Never rely on a bare name to compare against a constant’s value — use a guard (
case s if s == CONST:) or a dotted/qualified name pattern instead. - Use
|to combine related literal patterns instead of duplicating a case body:case 401 | 403:. - Prefer class patterns over long
isinstance()/getattr()chains when working with dataclasses, named tuples, or your own classes — it’s both shorter and self-documenting. - Keep guards focused on conditions that genuinely can’t be expressed as a pattern (comparisons between captured values, range checks); don’t use a guard-only case as a substitute for a normal
ifstatement. - Remember that string and byte sequences do not match generic sequence patterns like
[a, b]by default — this is intentional, to avoid a string being treated as a list of one-character strings. - Don’t reach for
matchjust to replace a two-branchif/else; it earns its keep once you have several branches or need to destructure structured data.
Practice Exercises
- Write a function
area(shape)whereshapeis a tuple such as("circle", radius),("rectangle", width, height), or("square", side). Usematchto compute and return the correct area for each shape, and returnNonefor anything else. - Write a function
classify_status(code)that usesmatchwith guards and range checks (e.g.case n if 200 <= n < 300:) to return"Success","Client Error","Server Error", or"Unknown"for a given HTTP status code. - Write a tiny calculator command parser: given a list like
["add", 3, 4]or["multiply", 2, 5], use a sequence pattern to destructure the operation name and operands, and return the computed result. Include a fallback case for unsupported operations.
Summary
match/case, added in Python 3.10, performs structural pattern matching, not just value comparison.- Patterns are tried top to bottom; the first matching
caseruns and no fall-through happens. - Sequence, mapping, and class patterns can destructure data and bind sub-values to names in one step.
- Guards (
if condition) add extra checks after a pattern has already matched and bound its names. - A bare lowercase name in a pattern always captures — it never compares against an existing variable's value.
- Order matters: put specific patterns before general ones, and end with
case _:as the catch-all.
