match-case
Structural pattern matching, and when it beats a chain of elifs.
What you will be able to do
- Write a match statement with literal, alternative, and wildcard patterns
- Explain why a bare name in a case captures instead of comparing
- Destructure tuples, lists, and dicts while matching them
- Add a guard to a pattern, and know when a guard means you wanted if instead
- Choose between match, an if/elif chain, and a dict of handlers
- Avoid the ordering and constant-name traps that make patterns silently wrong
The idea, in plain English
match-case arrived in Python 3.10. At first glance it looks like the switch statement other languages have, and for simple value comparison that is exactly how it behaves - one value, several literals, the first match wins.
That is not what it is for. The feature is structural pattern matching: a case describes the shape of the data, and if the data has that shape, the pieces are pulled out into names for you. `case {"type": "lesson_completed", "lesson_id": lid}` checks the type in one step and extracts the id in the same step.
That combination - test and destructure together - is what an elif chain cannot do without a separate pile of indexing and key lookups underneath it. Where the data is structured and its shape carries meaning, match reads as the data does.
There is one thing you must internalise before writing any of it: a bare name in a pattern does not compare against that name. It captures. `case status:` matches everything, and so does `case ADMIN:` even when ADMIN is a constant. Get that wrong and the code runs, silently, taking the wrong branch.
Worked example: Routing an event dict to the right handler by its shape.
A bare name captures - it never compares
This is the trap that catches everyone, and it produces no error. A pattern made of a plain identifier is a capture pattern: it matches any value at all and binds it to that name, exactly like case _ but with a usable name attached.
So `case status:` is not "when the value equals status". It is "match anything, and call it status". Anything after it is unreachable.
It gets worse with constants. If you define ADMIN = "admin" and write `case ADMIN:`, Python does not compare against "admin" - it matches everything and rebinds ADMIN to whatever was being matched. To compare against a constant you need a dotted name, which is why enums and class attributes are the usual answer: `case Role.ADMIN:` is a value pattern and does compare.
case "admin":Literal pattern. Compares by equality. What you usually want.case 404:Literal pattern. Compares by equality.case status:Capture pattern. Matches anything, binds it to status. Everything below is dead.case ADMIN:Also a capture pattern, even if ADMIN is a constant. It rebinds ADMIN. Almost never intended.case Role.ADMIN:Value pattern - a dotted name compares. Use an enum or a class attribute for named constants.case _:Wildcard. Matches anything without binding. The explicit default.case None:None, True, and False are compared by identity, not captured. They are special-cased.Watch out: Python raises a SyntaxError for a capture pattern followed by other cases only when it is literally `case _`. A named capture like `case status:` is accepted and silently swallows every branch below it.
Patterns describe shape, and destructure it
A sequence pattern matches by length and position, binding the elements as it goes. `case (0, y):` says "a two-element sequence whose first element is 0" and hands you the second as y. That is a type check, a value check, and an unpack in one line.
A mapping pattern is different in an important way: it is partial. `case {"type": "course"}` matches any dict that has that key with that value, no matter what else it contains. Extra keys never prevent a match, which is exactly what you want for API payloads that grow over time. A missing key does prevent it.
Sequence patterns match lists and tuples alike, and *rest collects whatever is left. Strings are deliberately excluded - `case [x, y]` will not match "ab", because matching a string as a sequence of characters is almost never what anyone means.
case []:An empty sequence.case [x]:Exactly one element, bound to x.case [x, y]:Exactly two. Matches a list or a tuple - but never a string.case [first, *rest]:One or more; rest is a list of whatever remains, possibly empty.case {"k": v}:A mapping containing key "k". Extra keys are fine - mapping patterns are partial.case {"k": v, **rest}:The same, with everything else collected into rest.case Point(x=0, y=y):A class pattern - the object is a Point and its x is 0. Needs __match_args__ for positional form.Guards, and when a guard means you wanted if
A guard is an if appended to a case: the pattern must match and the guard must be true. It is how you express a range, since a pattern can only compare for equality.
Guards are at their best combined with destructuring - `case (status, msg) if 400 <= status < 500:` checks the shape, extracts both parts, and narrows on one of them.
They are at their worst when they carry the entire decision. `case score if score >= 90:` is a capture pattern that matches anything plus a condition - which is an if statement wearing a costume. If every case is a bare capture with a guard, write if/elif and stop.
Tip: A quick test: if you delete every guard and the match still makes sense, it is a match. If deleting them leaves only case _ repeated, it was an if/elif chain all along.
match, if/elif, or a dict?
Three tools overlap here and each has a clear best case.
An if/elif chain suits ranges, mixed conditions, and anything where the branches ask different questions. A dict of handlers suits dispatch on one value where the branches all do the same kind of thing - it is constant time, extends without touching the code, and is trivially testable. match suits structured data whose shape varies, where you want to check and unpack at once.
The wrong reason to reach for match is that it looks like a switch. For seven string literals and nothing else, a dict is shorter and an elif chain is clearer.
Ranges, mixed conditionsif / elif. Patterns compare by equality; ranges need a guard, which defeats the point.One value, many literalsA dict of handlers, or an elif chain. match works but adds nothing.Varied structurematch. Different shapes of dict, different tuple lengths, optional keys.Test and unpack togethermatch. This is the thing nothing else does in one step.Dispatch that changes oftenA dict - adding a handler is adding a row, not editing a statement.Syntax and examples
status = "success"
match status:
case "success":
print("Operation completed")
case "failed":
print("Operation failed")
case "pending":
print("Operation is pending")
case _:
print("Unknown status")
# -> Operation completed
# Only the first matching case runs. There is no fall-through.role = "teacher"
# WRONG: a bare name matches anything and binds it
match role:
case role:
print("matched anything:", role) # always runs
case "admin":
print("admin") # unreachable
# WRONG: a plain constant name is still a capture
ADMIN = "admin"
match role:
case ADMIN:
print("matched, and ADMIN is now", ADMIN) # runs, ADMIN == "teacher"
# RIGHT: a literal, or a dotted name
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
TEACHER = "teacher"
match role:
case Role.ADMIN: # dotted - a value pattern, compares
print("admin")
case Role.TEACHER:
print("teacher") # runs
case _:
print("unknown")command = "quit"
match command:
case "quit" | "exit": # | means either
print("Closing")
case "start" | "restart":
print("Starting")
case _: # last, always
print("Unknown command")
# Put the wildcard first and nothing else can ever run:
value = 10
match value:
case _:
print("anything") # runs
case 10:
print("ten") # unreachablepoint = (0, 20)
match point:
case (0, 0):
print("Origin")
case (0, y): # first is 0, capture the second
print("On the Y axis at", y)
case (x, 0):
print("On the X axis at", x)
case (x, y):
print("Point", x, y)
# Lists match the same patterns, and *rest collects the tail
match [1, 2, 3, 4]:
case []:
print("empty")
case [only]:
print("one item:", only)
case [first, *rest]:
print("first:", first, "rest:", rest) # first: 1 rest: [2, 3, 4]
# A string is NOT matched as a sequence - deliberately
match "ab":
case [x, y]:
print("never reached")
case _:
print("strings are not sequence-matched")event = {"type": "lesson_completed", "lesson_id": 101, "at": "10:30"}
match event:
case {"type": "course_started", "course_id": cid}:
print("Course", cid, "started")
case {"type": "lesson_completed", "lesson_id": lid}:
print("Lesson", lid, "completed") # runs - "at" is ignored
case {"type": "quiz_submitted", "quiz_id": qid}:
print("Quiz", qid, "submitted")
case _:
print("Unknown event")
# Extra keys never block a match; a missing key does.
match {"name": "Chandu"}:
case {"name": n, "role": r}:
print(n, r)
case {"name": n}:
print(n, "has no role") # runs
# **rest captures whatever else was there
match event:
case {"type": t, **rest}:
print(t, rest) # lesson_completed {'lesson_id': 101, 'at': '10:30'}response = (404, "Course not found")
match response:
case (status, message) if 200 <= status < 300:
print("Success:", message)
case (status, message) if 400 <= status < 500:
print("Client error:", message) # runs
case (status, message) if 500 <= status < 600:
print("Server error:", message)
case _:
print("Unknown response")
# But this is an if statement in disguise - every case captures everything
score = 85
match score:
case s if s >= 90: grade = "A"
case s if s >= 75: grade = "B"
case _: grade = "C"
# ...so write it as one
if score >= 90:
grade = "A"
elif score >= 75:
grade = "B"
else:
grade = "C"payload = {"type": "course", "data": {"course_id": 101, "title": "Python"}}
match payload:
case {"type": "course", "data": {"course_id": cid, "title": title}}:
print(f"Course {cid}: {title}") # Course 101: Python
case {"type": "course"}:
print("A course, but the data is incomplete")
case _:
print("Unknown payload")
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
match Point(0, 5):
case Point(x=0, y=0):
print("Origin")
case Point(x=0, y=y):
print("On the Y axis at", y) # On the Y axis at 5
case Point():
print("Somewhere else")role = "teacher"
# 1. if/elif - fine, and the most familiar
if role == "admin":
label = "Manage platform"
elif role == "teacher":
label = "Manage courses"
else:
label = "View courses"
# 2. A dict - shortest, and extends without touching the logic
LABELS = {"admin": "Manage platform", "teacher": "Manage courses"}
label = LABELS.get(role, "View courses")
# 3. match - only earns its place once the data has shape
match {"role": role, "active": True}:
case {"role": "admin", "active": True}:
label = "Manage platform"
case {"role": "teacher", "active": True}:
label = "Manage courses"
case {"active": False}:
label = "Account inactive"
case _:
label = "View courses"
print(label)Tip: match and case are soft keywords. Code that already uses match as a variable name - a regex match, for instance - keeps working, which is why the feature could be added at all.
Pattern kinds
LiteralA number, string, or None/True/False. Compares by equality (identity for the three singletons).
case 404:
CaptureA bare name. Matches anything and binds it. Unreachable code follows it.
case status:
WildcardMatches anything and binds nothing. The explicit default, and it goes last.
case _:
ValueA dotted name. Compares against the constant - the correct way to use named constants.
case Role.ADMIN:
OrSeveral alternatives in one case.
case "exit" | "quit":
SequenceMatches by length and position. Lists and tuples, never strings.
case [x, *rest]:
MappingPartial - the listed keys must be present, extra keys are ignored.
case {"type": t}:ClassChecks the type and matches attributes.
case Point(x=0):
GuardAn if appended to any pattern. Both must hold.
case (s, m) if s > 400:
AsBinds the whole matched value while still matching a sub-pattern.
case [1, 2] as pair:
Rules worth keeping
Python 3.10+Earlier versions raise a SyntaxError. match and case are soft keywords, so existing variables named match still work.
No fall-throughExactly one case runs. There is no break, and none is needed.
Order mattersFirst match wins, as with elif. Specific before general, wildcard last.
No match, no elseNothing happens and no error is raised. Add case _ if that would be a bug.
Captures leakNames bound in a pattern stay bound after the match statement ends.
Strings are not sequencesDeliberate. case [x, y] does not match "ab".
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“r="teacher" match r: case r: print("any", r) case "admin": print("admin")”
“match {"a":1,"b":2}: case {"a": v}: print("matched", v)”
“match "ab": case [x,y]: print("seq") case _: print("not a sequence")”
“match (1,2): case (x,y): pass print(x, y)”
What usually goes wrong
A plain identifier is a capture pattern. It matches everything, binds the value to that name, and makes every case below it unreachable - with no error.
✗ match status:
case status:
...✓ match status:
case "active":
...The worst version of the same trap, because it looks so reasonable. case ADMIN: rebinds ADMIN rather than comparing with it. Use an enum or any dotted name.
✗ ADMIN = "admin"
match role:
case ADMIN: ...✓ class Role(StrEnum):
ADMIN = "admin"
match role:
case Role.ADMIN: ...The wildcard matches everything, so every case after it is dead. Python does raise a SyntaxError for this one - which is more help than you get from a named capture.
✗ case _:
...
case 10:
...✓ case 10:
...
case _:
...case 80: matches exactly 80. Patterns compare by equality; a range needs a guard, and a chain of guards usually means you wanted if/elif.
✗ match score:
case 80:
print("80 or more")✓ if score >= 80:
print("80 or more")Mapping patterns are partial. Extra keys never prevent a match, which is usually what you want for payloads - but it means a broad pattern high up will swallow more specific ones below it.
✗ case {"type": t}: # matches every event
...
case {"type": "x", "id": i}: # unreachable✓ case {"type": "x", "id": i}:
...
case {"type": t}:
...For one value against a handful of literals, a dict of handlers is shorter and extends without editing the branching. match earns its place when the data has structure.
✗ match cmd:
case "start": start()
case "stop": stop()✓ HANDLERS = {"start": start, "stop": stop}
HANDLERS.get(cmd, unknown)()When nothing matches, the statement does nothing at all - no error, no warning. If falling through silently would be a bug, add case _ and make it say so.
✗ match status:
case "ok": ...
case "fail": ...✓ match status:
case "ok": ...
case "fail": ...
case _:
raise ValueError(status)Best practices
- Use literals or dotted names in patterns; never a bare identifier you meant as a constant.
- Define named constants as an Enum so case Role.ADMIN compares rather than captures.
- Put case _ last, and include it whenever silently matching nothing would be a bug.
- Order patterns from most specific to most general, as with elif.
- Use match where the data has shape; use if/elif for ranges and mixed conditions.
- Use a dict of handlers for dispatch on a single value that changes often.
- Keep patterns shallow - two levels of nesting is usually the limit before a helper function reads better.
- If every case is a capture with a guard, rewrite it as if/elif.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Given day = "Monday", use match-case to print "Start of the week" for Monday, "Almost weekend" for Friday, "Weekend" for Saturday or Sunday, and "Midweek" for anything else.
Show hintHide hint
Saturday and Sunday share an outcome - use the | pattern.
Show solutionHide solution
day = "Monday"
match day:
case "Monday":
print("Start of the week") # runs
case "Friday":
print("Almost weekend")
case "Saturday" | "Sunday":
print("Weekend")
case _:
print("Midweek")Treat "admin" and "super_admin" as administrative, "teacher" as teaching, "student" as student, and anything else as unknown.
Show solutionHide solution
role = "super_admin"
match role:
case "admin" | "super_admin":
print("Administrative access") # runs
case "teacher":
print("Teaching access")
case "student":
print("Student access")
case _:
print("Unknown role")Given point = (0, 20), print "On the Y axis at 20". Handle the origin and the X axis too.
Show hintHide hint
Order matters - (0, 0) has to come before (0, y).
Show solutionHide solution
point = (0, 20)
match point:
case (0, 0):
print("Origin")
case (0, y):
print(f"On the Y axis at {y}") # runs
case (x, 0):
print(f"On the X axis at {x}")
case (x, y):
print(f"Point ({x}, {y})")Given items = [], print "No items". For a non-empty list, print the first item and how many remain.
Show solutionHide solution
items = []
match items:
case []:
print("No items") # runs
case [first, *rest]:
print(f"First: {first}, {len(rest)} more")Match an event dict of {"type": "quiz_submitted", "quiz_id": 25} and print "Quiz 25 submitted". Add cases for course_started and lesson_completed.
Show solutionHide solution
event = {"type": "quiz_submitted", "quiz_id": 25}
match event:
case {"type": "course_started", "course_id": cid}:
print(f"Course {cid} started")
case {"type": "lesson_completed", "lesson_id": lid}:
print(f"Lesson {lid} completed")
case {"type": "quiz_submitted", "quiz_id": qid}:
print(f"Quiz {qid} submitted") # runs
case _:
print("Unknown event")Show that `case role:` swallows every branch below it, then fix it so "admin" is matched properly.
Show hintHide hint
Replace the capture with a literal.
Show solutionHide solution
role = "teacher"
# Broken - the capture matches everything
match role:
case role:
print("captured:", role) # runs
case "admin":
print("admin") # never
# Fixed
match role:
case "admin":
print("admin")
case "teacher":
print("teacher") # runs
case _:
print("unknown")Event router
Route a stream of platform events to the right message. The events do not all have the same shape, and some are malformed - which is the reason to use match rather than a dict.
- Handle course_started, lesson_started, lesson_completed, quiz_submitted, and course_completed
- Extract the relevant id from each event in the same step as matching it
- Handle a quiz_submitted that also carries a score, differently from one that does not
- Report an event whose type is recognised but whose payload is incomplete
- Report an unknown event type separately from a malformed one
- Run every event below and check each line is what you expect
events = [
{"type": "course_started", "course_id": 101},
{"type": "lesson_completed", "lesson_id": 50},
{"type": "quiz_submitted", "quiz_id": 25, "score": 88},
{"type": "quiz_submitted", "quiz_id": 26},
{"type": "course_completed"},
{"type": "badge_earned", "badge": "streak"},
"not even a dict",
]
for event in events:
...Show one solutionHide solution
def describe(event):
match event:
# Most specific first: a quiz with a score outranks one without.
case {"type": "quiz_submitted", "quiz_id": qid, "score": score}:
return f"Quiz {qid} submitted, scored {score}"
case {"type": "quiz_submitted", "quiz_id": qid}:
return f"Quiz {qid} submitted"
case {"type": "course_started", "course_id": cid}:
return f"Course {cid} started"
case {"type": "course_completed", "course_id": cid}:
return f"Course {cid} completed"
case {"type": "lesson_started", "lesson_id": lid}:
return f"Lesson {lid} started"
case {"type": "lesson_completed", "lesson_id": lid}:
return f"Lesson {lid} completed"
# Known type, but the payload did not have what we needed.
case {"type": str() as known} if known in KNOWN_TYPES:
return f"Incomplete {known} event"
case {"type": str() as other}:
return f"Unknown event type: {other}"
case _:
return "Malformed event"
KNOWN_TYPES = {
"course_started", "course_completed",
"lesson_started", "lesson_completed",
"quiz_submitted",
}
events = [
{"type": "course_started", "course_id": 101},
{"type": "lesson_completed", "lesson_id": 50},
{"type": "quiz_submitted", "quiz_id": 25, "score": 88},
{"type": "quiz_submitted", "quiz_id": 26},
{"type": "course_completed"},
{"type": "badge_earned", "badge": "streak"},
"not even a dict",
]
for event in events:
print(describe(event))
# Course 101 started
# Lesson 50 completed
# Quiz 25 submitted, scored 88
# Quiz 26 submitted
# Incomplete course_completed event
# Unknown event type: badge_earned
# Malformed eventKey points
- match-case is structural pattern matching, added in Python 3.10.
- A case describes the shape of the data and binds its parts in the same step.
- A bare name is a capture pattern: it matches anything and makes later cases unreachable.
- A named constant in a pattern captures too - use a dotted name, usually an Enum member.
- case _ is the wildcard and must come last.
- | matches alternatives; a guard adds a condition to any pattern.
- Sequence patterns match lists and tuples by length and position, and never strings.
- *rest collects the remainder of a sequence; **rest collects the rest of a mapping.
- Mapping patterns are partial - extra keys are ignored, missing keys are not.
- Exactly one case runs. There is no fall-through and no break.
- When nothing matches and there is no case _, nothing happens and no error is raised.
- Use if/elif for ranges, a dict for single-value dispatch, and match when the data has shape.
Quick check before you move on
Interview questions
What is structural pattern matching, and how does it differ from a switch?
A switch compares one value against constants. Structural pattern matching describes the shape of data - its type, length, keys, and attributes - and binds the matching parts to names in the same step. `case {"type": "quiz", "id": i}` is a key check, a value check and an extraction at once, which no chain of equality tests does without separate lookups underneath.
Why does a bare name in a case capture rather than compare?
Because binding is the more common need in a pattern - `case (x, y)` has to introduce x and y, and treating bare names as lookups would make that impossible or ambiguous. The cost is the constant trap, which is why PEP 634 requires a dotted name for a value pattern and why enums are the idiomatic way to name pattern constants.
Why are mapping patterns partial but sequence patterns exact?
Because the data models differ in practice. A dict usually represents a record that grows - an API payload gains fields over time - so requiring an exact key set would break every consumer on every addition. A sequence has meaningful length and position, so matching a three-element pattern against a four-element list would be a genuine mismatch. Use **rest when you want the leftovers.
When would you not use match-case?
For ranges and mixed conditions, where every case would be a capture plus a guard - that is an if/elif chain with extra syntax. For dispatch on a single value that changes often, where a dict of handlers extends without editing the branching and is easier to test. And on anything that must run on Python 3.9 or earlier.
How do class patterns work?
case Point(x=0, y=y) checks isinstance first, then matches the named attributes, binding as it goes. Positional form - case Point(0, y) - requires the class to define __match_args__, which dataclasses and named tuples generate for you. It is what makes match genuinely useful over a hierarchy of node or message types.
What are the risks of complex patterns?
They fail silently. A pattern that does not match simply moves to the next case, so a wrong pattern looks like a missing case rather than an error - and a broad pattern placed above a narrow one swallows it with no warning. Deep nesting also gets unreadable quickly. Keep patterns shallow, order them specific to general, and include a case _ that raises when falling through would be a bug.
Quiz
- 1.
Which Python version introduced match-case, and what is it called?
- 2.
What does case _ mean?
- 3.
Why is `case ADMIN:` wrong when ADMIN is a constant?
- 4.
How do you match several values in one case?
- 5.
What is a guard?
- 6.
Does Python fall through from one case to the next?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned