Structural Pattern Matching (match/case)¶
What this lesson gives you
Python 3.10's most powerful addition — far more than a switch statement. It destructures data while it branches, and it's a modern feature worth knowing cold.
Estimated time: 20 min read · Part: Making Decisions: Control Flow
Learning objectives
- Use
match/caseto branch on literal values, OR-patterns, and wildcards. - Match the structure of dicts and sequences, binding inner values to names.
- Apply guard clauses (
ifconditions insidecase) for fine-grained filtering. - Match dataclass and class instances using class patterns.
- Decide when
matchis cleaner thanif/elifand when to stay with the simpler form.
For years Python had no switch statement — the idiomatic equivalent was either a chain of elif or a dispatch dictionary. Python 3.10 added something better: structural pattern matching with match/case. It doesn't just compare a value to constants — it can match the shape of data and pull pieces out in the same move. This is a 2026-current feature that signals you keep up with the language evolution and have moved beyond 3.9-era idioms.
The key word is structural. Where a C/Java switch compares one scalar value to constants, Python's match can ask: "Is this dict shaped like a click event with x and y coordinates? If so, bind those values to variables for me." That simultaneous matching and destructuring is the feature that makes it genuinely new, not just syntactic sugar for if/elif.
Literal patterns, OR patterns, and wildcards¶
def http_label(status: int) -> str:
match status:
case 200 | 201 | 204: # OR pattern — matches any of these literals
return "success"
case 301 | 302:
return "redirect"
case 400:
return "bad request"
case 401 | 403:
return "auth error"
case 404:
return "not found"
case status if 500 <= status < 600: # guard clause
return f"server error ({status})"
case _: # wildcard — the default case
return "unknown"
print(http_label(200)) # success
print(http_label(503)) # server error (503)
print(http_label(418)) # unknown
success server error (503) unknown
It destructures, not just compares
The real power is matching structure. A pattern can describe "a dict with these keys" or "a 3-element list" and bind the inner values to names at the same time. This makes parsing API responses, command inputs, or event payloads remarkably clean — replacing nested if isinstance(...) and 'key' in d chains with a flat, readable set of shapes. Think of it as "pattern → what to do with the extracted pieces."
Dict patterns, sequence patterns, and binding¶
def handle_event(event: dict) -> str:
match event:
# Dict pattern: match keys and bind values to names
case {"type": "click", "x": x, "y": y}:
return f"click at ({x}, {y})"
case {"type": "key", "key": k} if k.isupper():
return f"uppercase key: {k}" # guard: only uppercase keys
case {"type": "key", "key": k}:
return f"key pressed: {k}"
case {"type": t}: # bind unknown type
return f"unknown event type: {t}"
case _:
return "malformed event"
print(handle_event({"type": "click", "x": 10, "y": 5})) # click at (10, 5)
print(handle_event({"type": "key", "key": "ENTER"})) # uppercase key: ENTER
print(handle_event({"type": "scroll"})) # unknown event type: scroll
click at (10, 5) uppercase key: ENTER unknown event type: scroll
def parse_command(tokens: list[str]) -> str:
match tokens:
case []:
return "empty command"
case ["quit"]:
return "exiting"
case ["get", resource]: # exactly 2 elements
return f"GET {resource}"
case ["set", key, value]: # exactly 3 elements
return f"SET {key} = {value}"
case [cmd, *args]: # first element + rest captured
return f"unknown cmd {cmd!r} with {len(args)} args"
print(parse_command(["get", "users"])) # GET users
print(parse_command(["set", "timeout", "30"])) # SET timeout = 30
print(parse_command(["delete", "a", "b", "c"]))# unknown cmd 'delete' with 3 args
GET users SET timeout = 30 unknown cmd 'delete' with 3 args
Class patterns and dataclasses¶
Structural pattern matching shines with dataclasses and named types. You can match on the class of an object and simultaneously bind its attributes to variables, without any explicit isinstance calls. This is especially useful when processing heterogeneous event types that share a common attribute like type but differ in their other fields.
from dataclasses import dataclass
@dataclass
class UpgradeEvent:
account_id: int
new_plan: str
@dataclass
class ChurnEvent:
account_id: int
reason: str
@dataclass
class PaymentEvent:
account_id: int
amount: float
def handle(event) -> str:
match event:
case UpgradeEvent(account_id=aid, new_plan=plan):
return f"Account {aid} upgraded to {plan}"
case ChurnEvent(account_id=aid, reason=r):
return f"Account {aid} churned: {r}"
case PaymentEvent(account_id=aid, amount=amt) if amt > 10_000:
return f"Large payment from {aid}: ${amt:,.2f}"
case PaymentEvent(account_id=aid, amount=amt):
return f"Payment from {aid}: ${amt:,.2f}"
print(handle(UpgradeEvent(42, "enterprise")))
print(handle(PaymentEvent(99, 15000)))
Account 42 upgraded to enterprise Large payment from 99: $15,000.00
MATCH vs IF/ELIF DECISION GUIDE
═══════════════════════════════════════════════════════
Use MATCH when:
┌─────────────────────────────────────────────────────┐
│ Branching on the SHAPE or TYPE of structured data │
│ (dicts, lists, dataclasses, events) │
│ AND you want to destructure + bind in the same step│
└─────────────────────────────────────────────────────┘
Use IF/ELIF when:
┌─────────────────────────────────────────────────────┐
│ Simple comparison to constants (≤ 3 branches) │
│ Boolean conditions (not a type/shape dispatch) │
│ Numeric range checks │
│ Anything where dict dispatch would be cleaner │
└─────────────────────────────────────────────────────┘
Example of dict dispatch (better than match for simple routing):
handlers = {"upgrade": handle_upgrade, "churn": handle_churn}
handlers[event["type"]](event)
| Pattern syntax | What it matches | Binding |
|---|---|---|
case 42 | Literal value 42 | None |
case 1 \| 2 \| 3 | Any of these literals | None |
case _ | Anything (wildcard/default) | None |
case x | Anything — binds to x | x = matched value |
case [a, b] | Sequence of exactly 2 elements | a, b |
case [first, *rest] | Sequence with ≥ 1 element | first, rest |
case {"key": val} | Dict with "key" present | val |
case MyClass(attr=x) | Instance of MyClass | x = instance.attr |
case pattern if condition | Pattern matches AND guard is True | Depends on pattern |
case (a, b) \| (a, b, _) | Either of two sequence patterns | a, b |
| Feature | Python match | C/Java switch |
|---|---|---|
| Matches scalars | Yes | Yes (integers, chars) |
| Matches strings | Yes | Java 7+ only, C: no |
| Matches data shapes | Yes (dict/list/class structure) | No |
| Destructuring binding | Yes — extracts inner values to names | No |
| Guard clauses | Yes (case p if condition) | No (require extra if) |
| OR patterns | Yes (case a \| b) | Fall-through in C/Java |
| Wildcard/default | case _ | default: |
| Fall-through behavior | No — cases don't fall through | Yes in C (need explicit break) |
WHY match/case instead of if/isinstance The alternative to class pattern matching is a chain of if isinstance(event, UpgradeEvent): ... elif isinstance(event, ChurnEvent): ..., plus separate attribute access inside each branch. Match does both in one step, keeps the branch label and the data extraction together where they belong logically, and is visually scannable. It also exhausts cases more clearly — the reader can see all handled shapes at a glance.
HOW dict patterns work — keys subset, not exact A dict pattern matches any dict that has at least the specified keys — extra keys are allowed. case {"type": "click", "x": x, "y": y} matches a dict with those three keys (and possibly more). This is intentional: real-world dicts (API responses, event payloads) often have extra fields, and you shouldn't have to enumerate every field to match the shape you care about.
WHERE match/case appears in practice Webhook handlers (route by event type and extract payload fields), command parsers (CLI argument dispatch), protocol handlers (parse binary or text framing), AST processing (transform different node types), and state machines (transition logic based on state + event pairs). Any code that today has a long if/elif chain testing event["type"] or isinstance is a candidate for match/case improvement.
Knowledge check
InterviewWhat makes Python's match statement fundamentally more capable than a traditional switch from C or Java?
- Nothing — it's just renamed syntax for the same constant comparison.
- It matches the structure of data (dicts, lists, classes) and binds inner values to names while branching — destructuring, not just equality on constants.
- It runs faster than any
ifstatement in all cases. - It can only match integers.
Answer
It matches the structure of data (dicts, lists, classes) and binds inner values to names while branching — destructuring, not just equality on constants.
A C/Java switch compares one value to constants. Python's match does structural pattern matching: it can assert the shape of dicts/sequences/objects and simultaneously bind their components to variables. That destructuring capability is the key differentiator.
Knowledge check
Concept checkGiven case {"type": "click", "x": x}, does this pattern match the dict {"type": "click", "x": 5, "y": 10, "timestamp": 123}?
- No — the pattern specifies only
x, but the dict has extra keys, so it won't match. - Yes — dict patterns match any dict that has at least the specified keys. Extra keys are ignored.
xwill be bound to 5. - No — you must list all keys in the pattern for it to match.
- It raises a KeyError because
yis not mentioned in the pattern.
Answer
Yes — dict patterns match any dict that has at least the specified keys. Extra keys are ignored. x will be bound to 5.
Dict patterns do subset matching — they check that the specified keys are present with the specified values (or capture their values), but extra keys in the dict are completely ignored. This is deliberate: real-world dicts contain many fields, and requiring patterns to enumerate all of them would make the feature unusable for typical event payloads and API responses.
Knowledge check
AppliedYou're routing webhook events by type. You have 8 event types each needing different handler functions. Is match or a dispatch dict the better choice here?
- Always use
match— it's the modern feature. - Always use a dispatch dict — match is for edge cases only.
- A dispatch dict is often cleaner for pure routing:
handlers[event["type"]](event). Usematchwhen you also need to destructure event fields differently per type — the two approaches are complementary, not competing. - Use a long if/elif chain — it's the most readable.
Answer
A dispatch dict is often cleaner for pure routing: handlers[event["type"]](event). Use match when you also need to destructure event fields differently per type — the two approaches are complementary, not competing.
A dispatch dict ( handlers = {"upgrade": upgrade_fn, ...}; handlerstype ) is extremely concise when you're just routing to a handler by string key and all handlers have the same signature. Match adds value when you need to destructure the event differently per type simultaneously with branching — combining routing and extraction. Knowing when each is cleaner is the senior judgment.
Exercise
Write a function route_event(event: dict) -> str using structural pattern matching that handles these cases: (1) {"type": "deal_created", "deal_id": id, "mrr": mrr} — return a creation message; (2) {"type": "deal_updated", "deal_id": id, "field": field, "new_value": val} — return an update message; (3) {"type": "deal_closed", "deal_id": id, "outcome": "won"} — return a win message; (4) {"type": "deal_closed", "deal_id": id, "outcome": "lost"} — return a loss message; (5) anything else — return "unhandled event". Test all five cases.
Show solution
def route_event(event: dict) -> str:
match event:
case {"type": "deal_created", "deal_id": id, "mrr": mrr}:
return f"New deal {id} created with MRR ${mrr:,.2f}"
case {"type": "deal_updated", "deal_id": id, "field": field, "new_value": val}:
return f"Deal {id}: {field} updated to {val!r}"
case {"type": "deal_closed", "deal_id": id, "outcome": "won"}:
return f"Deal {id} WON! 🎉"
case {"type": "deal_closed", "deal_id": id, "outcome": "lost"}:
return f"Deal {id} lost. Follow-up scheduled."
case {"type": t}:
return f"Unhandled event type: {t!r}"
case _:
return "Malformed event: missing 'type'"
# Tests:
print(route_event({"type": "deal_created", "deal_id": 42, "mrr": 4999.99}))
print(route_event({"type": "deal_updated", "deal_id": 42, "field": "stage", "new_value": "negotiation"}))
print(route_event({"type": "deal_closed", "deal_id": 42, "outcome": "won"}))
print(route_event({"type": "deal_closed", "deal_id": 99, "outcome": "lost"}))
print(route_event({"type": "contact_created", "contact_id": 7}))
Key takeaways
match/case(Python 3.10+) is structural pattern matching — not a plain switch.- It matches the shape of dicts, lists, and objects and binds inner values to names simultaneously.
- Dict patterns do subset matching — extra keys are ignored.
case _:is the wildcard/default;|matches multiple literals;*restcaptures sequence remainders.- Guard clauses (
case p if condition) add fine-grained filtering within a pattern. - Use
matchfor structural dispatch; use dispatch dicts for pure routing by string key — they're complementary.