Beyond if/elif: A Practical Deep Dive into Python's match/case
Structural pattern matching is more than a switch statement. Learn how match/case destructures sequences, mappings, and objects — plus guards, OR-patterns, and the two gotchas that trip everyone up.
When Python 3.10 introduced match/case, a lot of developers filed it away as "Python finally got a switch statement" and moved on. That framing sells it short. Structural pattern matching doesn't just compare a value against a list of constants — it destructures data, binding pieces of it to names as it checks the shape. Once that clicks, whole categories of nested if/isinstance/len/index-checking code collapse into a few readable lines.
This guide walks through the pattern types from simplest to most powerful, with runnable examples, and ends with the two pitfalls that bite almost everyone. All examples require Python 3.10 or newer.
The basics: literals, capture, and the wildcard
A match statement evaluates a subject once, then tries each case from top to bottom. The first pattern that matches wins, and execution does not fall through to the next case. The simplest patterns are literal values, an OR-pattern using |, and the wildcard _, which matches anything without binding it.
def http_status(code):
match code:
case 200 | 201 | 204:
return "Success"
case 301 | 302:
return "Redirect"
case 400 | 404:
return "Client error"
case 500:
return "Server error"
case _:
return "Unknown"
print(http_status(200)) # Success
print(http_status(302)) # Redirect
print(http_status(999)) # UnknownA bare name in a pattern is a capture pattern: it matches anything and binds the value to that name. This is what makes the wildcard _ special — it is the one name that captures nothing.
def describe(x):
match x:
case 0:
return "zero"
case n: # captures any other value into n
return f"number {n}"
print(describe(0)) # zero
print(describe(7)) # number 7Sequence patterns: destructuring lists and tuples
Sequence patterns match by shape and length, binding elements to names as they go. A starred name (*rest) absorbs the remaining items, exactly like it does in ordinary unpacking. This is where pattern matching starts to feel different from a switch.
def parse_command(cmd):
match cmd.split():
case ["go", direction]:
return f"Moving {direction}"
case ["drop", *items]:
return f"Dropping {items}"
case ["quit"]:
return "Bye"
case _:
return "Unrecognized command"
print(parse_command("go north")) # Moving north
print(parse_command("drop sword shield"))# Dropping ['sword', 'shield']
print(parse_command("quit")) # ByeNote that [...] and (...) are interchangeable here — both match any sequence (list or tuple), but deliberately not strings, even though strings are technically sequences. That exception is intentional, so case [x, y] never accidentally matches a two-character string.
Mapping patterns: matching dictionaries by key
Mapping patterns check that specific keys are present and match their values. Crucially, they match on a subset of keys — extra keys in the dictionary are ignored — which makes them ideal for handling JSON-like event payloads where you only care about a few fields.
def handle(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at {x},{y}"
case {"type": "key", "value": v}:
return f"Key {v}"
case {"type": t}:
return f"Other event: {t}"
print(handle({"type": "click", "x": 1, "y": 2})) # Click at 1,2
print(handle({"type": "key", "value": "Esc"})) # Key Esc
print(handle({"type": "scroll", "delta": 3})) # Other event: scrollClass patterns: matching objects by type and attribute
Class patterns are the feature that pushes match well beyond a switch. They check that the subject is an instance of a class, then match against its attributes — either by keyword or, for supported classes, by position.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def locate(p):
match p:
case Point(x=0, y=0):
return "origin"
case Point(x=0, y=y):
return f"y-axis at {y}"
case Point(x=x, y=0):
return f"x-axis at {x}"
case Point(x=x, y=y):
return f"({x}, {y})"
print(locate(Point(0, 0))) # origin
print(locate(Point(0, 5))) # y-axis at 5
print(locate(Point(3, 4))) # (3, 4)Positional matching and __match_args__
Writing Point(x=x, y=y) every time gets verbose. Positional class patterns like Point(x, y) work when the class defines __match_args__, a tuple naming the attributes in positional order. Dataclasses and NamedTuple generate this for you automatically, which is one more reason to reach for them.
@dataclass
class Circle:
radius: float
# dataclass auto-generates __match_args__ = ("radius",)
def describe_shape(shape):
match shape:
case Circle(0):
return "a point"
case Circle(r):
return f"circle with radius {r}"
print(describe_shape(Circle(0))) # a point
print(describe_shape(Circle(2.5))) # circle with radius 2.5Guards, as-patterns, and nesting
A guard is an if clause attached to a case. The pattern must match and the guard must be true; otherwise matching continues to the next case. Guards let you express conditions that structure alone can't capture.
def classify(p):
match p:
case Point(x=x, y=y) if x == y:
return "on the diagonal"
case Point(x=x, y=y) if x > y:
return "below the diagonal"
case _:
return "above the diagonal"
print(classify(Point(2, 2))) # on the diagonal
print(classify(Point(5, 1))) # below the diagonalThe as keyword binds the value matched by a sub-pattern to a name, which is handy when you want both the destructured parts and the whole. Patterns also nest freely, so you can match deeply structured data — like a tiny expression tree — in a single case.
def evaluate(node):
match node:
case {"op": "+", "args": [left, right]}:
return evaluate(left) + evaluate(right)
case {"value": v}:
return v
case int() as n: # match an int, bind it as n
return n
tree = {"op": "+", "args": [{"value": 3}, {"value": 4}]}
print(evaluate(tree)) # 7Two pitfalls that catch everyone
1. A bare name is always a capture, never a comparison
This is the single most common surprise. If you write a plain variable name in a pattern hoping to compare against its value, you instead rebind that name and match everything.
STATUS_OK = 200
def check(code):
match code:
case STATUS_OK: # BUG: captures into STATUS_OK, matches ANY code
return "ok"
case _:
return "other"
print(check(999)) # "ok" -- almost certainly not what you wantedTo compare against a named constant, use a dotted name (an attribute lookup), which Python treats as a value pattern. Enums are the idiomatic fix:
from enum import Enum
class Status(Enum):
OK = 200
NOT_FOUND = 404
def check(code):
match code:
case Status.OK.value: # dotted -> compared by value
return "ok"
case _:
return "other"
print(check(200)) # ok
print(check(999)) # other2. Positional class patterns need __match_args__
If a class doesn't define __match_args__ (a plain class with a hand-written __init__ won't), then a positional pattern like case Plain(1) raises a TypeError at match time, not a silent miss. Either add __match_args__, match by keyword (case Plain(a=1)), or use a dataclass. Keyword matching always works and is often the clearer choice.
When to reach for it (and when not to)
Pattern matching shines when you're branching on the shape of data: parsing command tokens, dispatching on event dictionaries, walking an AST or nested JSON, or handling algebraic-data-type-style objects. In those cases it replaces brittle chains of isinstance, len, indexing, and key lookups with something you can read top to bottom.
For a simple equality check against a handful of constants, a plain if/elif or a dictionary dispatch is still perfectly good — and clearer to readers who aren't fluent in the newer syntax. Match isn't a mandate to rewrite every conditional; it's a sharp tool for the structural cases where the old approach was already getting ugly.
Wrap-up and next steps
The mental shift is this: match is about shape and destructuring, not just value equality. Start with sequence and mapping patterns on data you already parse by hand, add class patterns once you're comfortable, and keep the two pitfalls in mind — bare names capture, and positional class patterns need __match_args__. From here, try refactoring one real isinstance ladder in your codebase into class patterns, and read PEP 634 (the specification) and PEP 636 (the tutorial) for the full grammar. You'll likely find a few places where three nested if statements become one obvious case.