Python 3.10, released in October 2021, introduced one of the most ambitious additions to the language in years: the match statement. Officially called structural pattern matching, this feature goes far beyond what most developers expect when they hear "switch/case." It does not just compare a value against a list of constants. It deconstructs data structures, binds variables, checks types, and validates shapes — all in a single, readable construct.
The feature was significant enough to require three separate PEPs to describe it. PEP 634 provided the technical specification, PEP 635 laid out the motivation and rationale, and PEP 636 offered a tutorial. The authors — Brandt Bucher and Guido van Rossum — split the original PEP 622 into these three documents after the Python Steering Council and community raised concerns about the feature's complexity and learnability.
This article walks through every pattern type with practical, real-world examples, explains the gotchas that trip up even experienced Python developers, and shows you where match genuinely improves your code versus where if/elif chains remain the better choice.
The Basics: Syntax and Simple Matching
The match statement evaluates a subject expression once, then checks it against one or more case clauses in order. The first matching case executes, and the rest are skipped.
def http_status(code):
match code:
case 200:
return "OK"
case 301:
return "Moved Permanently"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
case _:
return f"Unknown status: {code}"
print(http_status(404)) # "Not Found"
print(http_status(999)) # "Unknown status: 999"
The underscore _ is a wildcard pattern. It matches anything and serves the same purpose as default in a C-style switch or else in an if/elif chain. It must appear last, since it catches everything.
This looks simple, and it is — but this is also the most superficial use of match. If literal matching were all it did, there would be little reason to prefer it over a dictionary lookup or a basic if/elif chain. The real power lies in what follows.
Pattern Types: The Full Toolkit
The match statement supports ten distinct pattern types. Each one gives you a different way to interrogate and deconstruct the subject value.
1. Literal Patterns
Match against fixed values: integers, strings, booleans, None, floats.
def describe(value):
match value:
case True:
return "Boolean true"
case None:
return "Nothing here"
case 0:
return "Zero"
case "hello":
return "A greeting"
case _:
return "Something else"
True, False, and None are compared using is (identity), while all other literals use == (equality). This means case True will not match the integer 1, even though 1 == True is True in normal Python.
2. Capture Patterns
A bare name in a case clause does not look up a variable — it binds one. This is the single most important thing to understand about the match statement, and the most common source of bugs.
def greet(name):
match name:
case "Alice":
return "Hey Alice!"
case other:
return f"Hello, {other}!"
print(greet("Alice")) # "Hey Alice!"
print(greet("Bob")) # "Hello, Bob!" — "Bob" is captured into 'other'
The variable other captures whatever value name holds if no previous case matched. This is equivalent to _ except it gives you a name to work with.
3. Sequence Patterns
Match against lists or tuples by specifying the expected structure. This is where match starts to feel genuinely different from if/elif.
def handle_command(command):
match command.split():
case ["quit"]:
return "Goodbye!"
case ["go", direction]:
return f"Moving {direction}"
case ["pick", "up", item]:
return f"Picked up {item}"
case ["drop", *items]:
return f"Dropped: {', '.join(items)}"
case []:
return "Empty command"
case _:
return "Unknown command"
print(handle_command("go north")) # "Moving north"
print(handle_command("pick up sword")) # "Picked up sword"
print(handle_command("drop key shield")) # "Dropped: key, shield"
This example is adapted from PEP 636, which uses a text adventure game to demonstrate pattern matching. The sequence pattern ["go", direction] matches any two-element sequence whose first element is the string "go", and captures the second element into the variable direction. The *items syntax works like extended unpacking in regular assignments, capturing all remaining elements into a list.
Sequence patterns check both structure (length) and content simultaneously. Under the hood, the interpreter verifies that the subject is a sequence (via isinstance checks against collections.abc.Sequence), confirms the length matches, and then tests each element against its subpattern.
4. Mapping Patterns
Match against dictionaries by specifying required keys and capturing their values. Extra keys in the subject are ignored by default — the pattern only checks for the keys you specify.
def process_event(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "keypress", "key": key}:
return f"Key pressed: {key}"
case {"type": "scroll", "direction": d, **rest}:
return f"Scroll {d}, extra data: {rest}"
case _:
return "Unknown event"
print(process_event({"type": "click", "x": 100, "y": 250, "timestamp": 123}))
# "Click at (100, 250)" — "timestamp" is silently ignored
This is enormously useful for working with JSON payloads, API responses, configuration files, and any other dict-heavy data. The **rest syntax captures remaining key-value pairs into a new dictionary, similar to **kwargs in function signatures.
5. Class Patterns
Match against instances of classes by checking their type and optionally extracting attributes. This is what Brandt Bucher, co-author of PEP 634, described at PyCon 2022 as the most exciting pattern type.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
radius: float
@dataclass
class Rectangle:
corner: Point
width: float
height: float
def describe_shape(shape):
match shape:
case Circle(center=Point(x=0, y=0), radius=r):
return f"Circle at origin with radius {r}"
case Circle(center=Point(x=x, y=y), radius=r):
return f"Circle at ({x}, {y}) with radius {r}"
case Rectangle(corner=Point(x=x, y=y), width=w, height=h) if w == h:
return f"Square at ({x}, {y}) with side {w}"
case Rectangle(corner=Point(x=x, y=y), width=w, height=h):
return f"Rectangle at ({x}, {y}), {w}x{h}"
case _:
return "Unknown shape"
print(describe_shape(Circle(Point(0, 0), 5)))
# "Circle at origin with radius 5"
print(describe_shape(Rectangle(Point(1, 2), 4, 4)))
# "Square at (1, 2) with side 4"
Notice the nesting: Circle(center=Point(x=0, y=0), radius=r) checks that the subject is a Circle, that its center attribute is a Point, that the point's x and y are both 0, and captures the radius. No isinstance calls. No attribute access chains. No temporary variables.
For built-in types like int, str, float, and bytes, you can use positional syntax as a shorthand. case int(n) matches any integer and captures its value into n. case str() matches any string without capturing.
6. OR Patterns
Combine multiple patterns with | to match any of them.
def classify_char(char):
match char:
case 'a' | 'e' | 'i' | 'o' | 'u':
return "lowercase vowel"
case 'A' | 'E' | 'I' | 'O' | 'U':
return "uppercase vowel"
case _:
return "consonant or other"
If any alternative in an OR pattern contains capture variables, all alternatives must capture the same set of names. This ensures the body of the case can safely reference those names regardless of which alternative matched.
7. AS Patterns
Capture the entire matched value (or a sub-value) while also testing its structure.
def process(data):
match data:
case {"type": "user", "info": {"name": str()} as info}:
return f"User info: {info}"
case [int() as first, *rest]:
return f"Starts with integer {first}, {len(rest)} more items"
The as keyword lets you name a value that has already been validated by a pattern. This is particularly useful when you need to pass the matched value to another function without re-accessing it.
8. Guard Clauses
Add an if condition after a pattern to impose additional constraints that patterns alone cannot express.
def categorize_age(person):
match person:
case {"name": name, "age": age} if age < 0:
return f"Invalid age for {name}"
case {"name": name, "age": age} if age < 13:
return f"{name} is a child"
case {"name": name, "age": age} if age < 18:
return f"{name} is a teenager"
case {"name": name, "age": age}:
return f"{name} is an adult"
print(categorize_age({"name": "Alex", "age": 15})) # "Alex is a teenager"
Guards are evaluated only after the pattern itself matches. If the guard evaluates to false, matching continues to the next case — the pattern is not considered a match.
9. Wildcard Pattern
The underscore _ matches any value without binding it to a name. It is always used as the final catch-all case.
10. Value Patterns
A dotted name (like Status.OK or http.HTTPStatus.NOT_FOUND) is looked up and compared by value, not captured. This is the correct way to match against named constants. See the section below on the variable capture trap for why this distinction matters.
The Variable Capture Trap: The Most Dangerous Gotcha
This is the single biggest source of confusion with the match statement, and it deserves its own section because getting it wrong produces bugs that silently do the wrong thing rather than raising an error.
In a case clause, a bare name does not look up a previously defined variable. It captures the subject into that name. This is fundamentally different from how names work everywhere else in Python.
The following code contains a silent, logic-destroying bug. Can you spot it before reading the explanation?
# THIS IS A BUG
STATUS_OK = 200
STATUS_NOT_FOUND = 404
def check_status(code):
match code:
case STATUS_OK: # Does NOT compare against 200!
return "All good" # This captures code into STATUS_OK
case STATUS_NOT_FOUND:
return "Not found"
# check_status(999) returns "All good" — STATUS_OK is now 999
The case STATUS_OK line does not compare code against 200. It matches anything and binds the value to a new local variable called STATUS_OK. The first case always succeeds, and the second case is unreachable. Python will raise a SyntaxError with the message "name capture makes remaining patterns unreachable" — but only if it can detect the issue statically.
PEP 635 explains the rationale: the PEP authors considered several alternatives, including using uppercase names for constants and lowercase for captures, or requiring special syntax like $name for captures. They ultimately chose the dotted-name rule as the least disruptive option.
The correct approaches:
Use literal values directly:
def check_status(code):
match code:
case 200:
return "All good"
case 404:
return "Not found"
Use dotted names (enums, class attributes, module constants):
from enum import IntEnum
class Status(IntEnum):
OK = 200
NOT_FOUND = 404
SERVER_ERROR = 500
def check_status(code):
match code:
case Status.OK:
return "All good"
case Status.NOT_FOUND:
return "Not found"
case Status.SERVER_ERROR:
return "Server error"
As PEP 636's tutorial states: "Named constants must be dotted names to prevent them from being interpreted as capture variables." Any name with a dot — Status.OK, http.HTTPStatus.OK, math.pi — is treated as a value to compare against. Any name without a dot is a capture.
Use a guard when dotted names are impractical:
THRESHOLD = 100
def check_value(x):
match x:
case value if value == THRESHOLD:
return "At threshold"
case value if value > THRESHOLD:
return "Above threshold"
case _:
return "Below threshold"
Real-World Examples
Parsing API Responses
def handle_api_response(response):
match response:
case {"status": "success", "data": {"users": [*users]}}:
return f"Found {len(users)} users"
case {"status": "success", "data": {"user": user}}:
return f"Found user: {user.get('name', 'unknown')}"
case {"status": "error", "code": code, "message": msg}:
return f"Error {code}: {msg}"
case {"status": "error", **rest}:
return f"Error with details: {rest}"
case _:
return "Unexpected response format"
This replaces what would typically be a cascade of if "status" in response and if "data" in response and isinstance(response["data"]["users"], list) checks.
Processing AST-Like Structures
Pattern matching shines for recursive data structures — and this is no coincidence. Guido van Rossum had been working on the Mypy type checker and CPython compiler optimizations, both of which involve heavy AST traversal.
@dataclass
class Num:
value: float
@dataclass
class BinOp:
op: str
left: object
right: object
@dataclass
class UnaryOp:
op: str
operand: object
def evaluate(expr):
match expr:
case Num(value=v):
return v
case BinOp(op="+", left=left, right=right):
return evaluate(left) + evaluate(right)
case BinOp(op="-", left=left, right=right):
return evaluate(left) - evaluate(right)
case BinOp(op="*", left=left, right=right):
return evaluate(left) * evaluate(right)
case BinOp(op="/", left=left, right=right):
return evaluate(left) / evaluate(right)
case UnaryOp(op="-", operand=operand):
return -evaluate(operand)
case _:
raise ValueError(f"Unknown expression: {expr}")
# Evaluate: (3 + 4) * -2
tree = BinOp("*", BinOp("+", Num(3), Num(4)), UnaryOp("-", Num(2)))
print(evaluate(tree)) # -14
The equivalent if/elif version requires explicit isinstance checks and attribute access for every case. The match version reads almost like a grammar specification.
State Machine Transitions
from enum import Enum, auto
class OrderState(Enum):
PENDING = auto()
CONFIRMED = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
def transition(current_state, action):
match (current_state, action):
case (OrderState.PENDING, "confirm"):
return OrderState.CONFIRMED
case (OrderState.PENDING, "cancel"):
return OrderState.CANCELLED
case (OrderState.CONFIRMED, "ship"):
return OrderState.SHIPPED
case (OrderState.CONFIRMED, "cancel"):
return OrderState.CANCELLED
case (OrderState.SHIPPED, "deliver"):
return OrderState.DELIVERED
case (state, action):
raise ValueError(
f"Invalid transition: {action} from {state.name}"
)
print(transition(OrderState.PENDING, "confirm")) # OrderState.CONFIRMED
print(transition(OrderState.CONFIRMED, "ship")) # OrderState.SHIPPED
Matching on a tuple of (state, action) makes every valid transition explicit and readable. Invalid transitions naturally fall through to the final catch-all case.
Type-Based Dispatch
def serialize(obj):
match obj:
case bool(): # Must come before int() — bool is a subclass of int
return "true" if obj else "false"
case int() | float():
return str(obj)
case str():
return f'"{obj}"'
case list():
items = ", ".join(serialize(item) for item in obj)
return f"[{items}]"
case dict():
pairs = ", ".join(
f"{serialize(k)}: {serialize(v)}" for k, v in obj.items()
)
return "{" + pairs + "}"
case None:
return "null"
case _:
raise TypeError(f"Cannot serialize {type(obj).__name__}")
print(serialize({"name": "Alice", "scores": [95, 87, True]}))
# '{"name": "Alice", "scores": [95, 87, true]}'
Note the ordering: case bool() must appear before case int() because bool is a subclass of int in Python. If int() came first, True and False would match as integers. Pattern order matters.
match vs. if/elif: When to Use Which
The match statement is not a universal replacement for if/elif. Ben Hoyt analyzed real-world Python codebases and found that typical application code uses elif in only about 0.3% of lines — and many of those cases would not benefit from match. His conclusion was that the PEP authors, working on compiler and type-checker code, "regularly write the kind of code that does benefit from pattern matching, but most application developers and script writers will need match far less often."
Use match when:
- You are deconstructing nested data structures (JSON payloads, ASTs, protocol messages).
- You are dispatching on the type and shape of objects simultaneously.
- You are implementing state machines with explicit transition tables.
- You are parsing command structures where both length and content matter.
- You need to bind variables while testing structure in the same operation.
Use if/elif when:
- You are testing simple boolean conditions.
- Your conditions involve function calls or computed values that do not map to patterns.
- You are comparing against a small number of literal values where a dictionary lookup would suffice.
- The equivalent
matchwould require guards on every case, negating the readability benefit. - Your team or codebase targets Python versions older than 3.10.
Common Mistakes
Putting a too-general case first. Cases are evaluated top-to-bottom. A capture pattern like case x matches everything, making subsequent cases unreachable. Always order cases from most specific to most general.
Forgetting that bool is a subclass of int. case int() matches True and False. If you need to distinguish booleans from integers, test bool() first.
Using bare names when you mean constants. As covered above, case MY_CONSTANT captures rather than compares. Use case MyClass.CONSTANT or literal values.
Assuming match is an expression. Unlike Rust's match or Haskell's case expressions, Python's match is a statement. It does not return a value. You cannot write result = match x: .... You must assign inside each case body.
Neglecting the wildcard. If no case matches and there is no wildcard _, execution simply continues after the match block with no error. This silent pass-through can hide bugs. Add case _ with an explicit error or default behavior for safety.
The History: A Twenty-Year Journey
The idea of a switch/case statement for Python is nearly as old as Python itself. Marc-Andre Lemburg proposed PEP 275 ("Switching on Multiple Values") in 2001. Guido van Rossum wrote PEP 3103 ("A Switch/Case Statement") in 2006, targeting Python 3. Both were rejected.
What changed was the ambition. Instead of mimicking C's switch, the PEP 634 authors drew inspiration from pattern matching in functional languages — Haskell, Scala, Erlang, Rust, and F# — where matching is about decomposing data structures, not just comparing values. As PEP 635 notes, structural pattern matching was already present in Python in a limited form through sequence unpacking assignments, and the new proposal was designed to build on that existing idiom.
Van Rossum introduced PEP 622 to the python-dev mailing list on June 23, 2020. He was candid about the difficulty of the design process, writing that "the design space for such a match statement is enormous" and that "for many key decisions the authors have clashed, in some cases we have gone back and forth several times, and a few uncomfortable compromises were struck."
The community reaction was mixed. Gregory P. Smith raised concerns about code readability for developers who were not Python experts. Daniel Moisset called the variable capture behavior "a bit like a foot-gun." The __match__ protocol for customizing matching behavior drew extensive complaints and was eventually dropped. After substantial revision, PEP 622 was superseded by the trio of PEPs 634, 635, and 636, and the feature was accepted for Python 3.10.
The keyword match was chosen carefully. As a "soft keyword," it is only treated as a keyword when used in the specific match/case syntax. Existing code that uses match as a variable or function name continues to work. This avoided the painful backward-compatibility breaks that new keywords typically cause.
Quick Reference
match subject:
case literal: # Literal pattern (200, "hello", True, None)
case name: # Capture pattern (binds value to name)
case _: # Wildcard pattern (matches anything, no binding)
case Class(attr=pattern): # Class pattern (isinstance + attribute check)
case [a, b, *rest]: # Sequence pattern (list/tuple structure)
case {"key": value, **rest}: # Mapping pattern (dict structure)
case pattern1 | pattern2: # OR pattern (match either)
case pattern as name: # AS pattern (match and bind whole value)
case pattern if condition: # Guard (pattern + additional condition)
case Namespace.CONSTANT: # Value pattern (dotted name, compared by ==)
Key rules to remember: Bare names capture; dotted names compare. Cases are evaluated top-to-bottom; first match wins. True/False/None use is; everything else uses ==. Mapping patterns ignore extra keys. Sequence patterns require exact length (unless *rest is used). Guards are evaluated only after the pattern matches. The _ wildcard discards the value — it never binds.