Intermediate
You have probably written dozens of if-elif chains that check a variable against a list of possible values. Maybe it is an HTTP status code, a command from user input, or a message type from an API. The chain starts small, then grows to 15 branches, and suddenly the logic is hard to follow and even harder to extend. Python 3.10 introduced structural pattern matching with the match and case statements to solve exactly this problem.
Structural pattern matching is built into Python 3.10 and later — no extra libraries needed. It goes far beyond a simple switch statement. You can match against literal values, destructure sequences and dictionaries, bind variables, add guard conditions, and even match class instances by their attributes. If you have used pattern matching in Rust, Scala, or Elixir, Python’s version will feel familiar but with its own Pythonic style.
In this article, you will learn how match-case works starting with a quick example, then move through literal patterns, sequence unpacking, mapping patterns, class patterns, guard clauses, and OR patterns. We will finish with a real-life CLI command parser that ties everything together. By the end, you will be able to replace complex branching logic with clean, readable pattern matching code.
Python match-case: Quick Example
Here is the simplest useful example of match-case — handling HTTP status codes. This runs on Python 3.10 or later:
# quick_example.py
def describe_status(code):
match code:
case 200:
return "OK -- request succeeded"
case 404:
return "Not Found -- resource does not exist"
case 500:
return "Server Error -- something broke on the server"
case _:
return f"Unknown status code: {code}"
print(describe_status(200))
print(describe_status(404))
print(describe_status(999))
Output:
OK -- request succeeded
Not Found -- resource does not exist
Unknown status code: 999
The match statement evaluates the subject expression (code) and compares it against each case pattern in order. The first matching pattern wins, and its block runs. The underscore _ is the wildcard pattern — it matches anything and acts as your default branch, similar to else in an if-chain.
This looks like a switch statement on the surface, but as you will see in the following sections, match-case can destructure data structures, bind variables, and match complex nested objects — things no switch statement can do.
What Is Structural Pattern Matching and Why Use It?
Structural pattern matching lets you check whether a value has a particular structure and extract parts of it in a single step. Think of it as an X-ray machine for your data: you describe the shape you expect, and Python checks if the data fits that shape while pulling out the pieces you need.
The key difference from if-elif chains is that pattern matching is declarative. Instead of writing procedural code that tests conditions one by one, you describe what the data should look like. Python handles the checking and unpacking for you.
| Feature | if-elif Chain | match-case |
|---|---|---|
| Simple value comparison | Works fine | Works fine, slightly cleaner |
| Destructuring sequences | Manual indexing or unpacking | Built-in with capture variables |
| Nested data extraction | Multiple lines of checks | Single pattern describes the shape |
| Type checking + attribute access | isinstance() + getattr() | Class patterns handle both at once |
| Combining conditions | and/or in conditions | Guards and OR patterns |
| Readability at 5+ branches | Gets messy fast | Each case is self-contained |
Pattern matching shines when you need to handle multiple message types, parse command structures, process API responses with varying shapes, or route events based on their content. For simple two-way checks, a regular if-else is still the right tool.
Matching Literal Values
The most basic use of match-case is matching against literal values — integers, strings, booleans, and None. This is the direct replacement for a long if-elif chain that compares a variable against constants:
# literal_patterns.py
def get_day_type(day):
match day.lower():
case "monday" | "tuesday" | "wednesday" | "thursday" | "friday":
return "weekday"
case "saturday" | "sunday":
return "weekend"
case _:
return "not a valid day"
print(get_day_type("Monday"))
print(get_day_type("Saturday"))
print(get_day_type("Funday"))
Output:
weekday
weekend
not a valid day
The pipe operator | creates an OR pattern, letting you match multiple values in a single case. This is much cleaner than writing if day in ("monday", "tuesday", ...) when each group needs different handling. Notice we call .lower() on the subject expression itself — all transformations happen before matching begins.
Destructuring Sequences
One of the most powerful features of match-case is sequence patterns. You can match lists and tuples by their structure, extract specific elements into variables, and even capture variable-length remainders with the star operator:
# sequence_patterns.py
def process_command(command_parts):
match command_parts:
case ["quit"]:
return "Exiting program"
case ["hello", name]:
return f"Hello, {name}!"
case ["move", direction, steps]:
return f"Moving {direction} by {steps} steps"
case ["add", *items]:
return f"Adding {len(items)} items: {', '.join(items)}"
case []:
return "Empty command"
case _:
return f"Unknown command: {command_parts}"
print(process_command(["quit"]))
print(process_command(["hello", "Alice"]))
print(process_command(["move", "north", "5"]))
print(process_command(["add", "milk", "eggs", "bread"]))
print(process_command([]))
Output:
Exiting program
Hello, Alice!
Moving north by 5 steps
Adding 3 items: milk, eggs, bread
Empty command
Each case describes the shape of the list. The pattern ["hello", name] matches any two-element list where the first element is literally "hello", and it binds the second element to the variable name. The *items pattern captures all remaining elements after "add", similar to how *args works in function signatures. This lets you handle variable-length commands without writing manual length checks.
Matching Dictionaries
Mapping patterns let you match dictionaries by checking for specific keys and extracting their values. This is incredibly useful for processing JSON responses from APIs where the shape of the data tells you what type of message or event you are dealing with:
# mapping_patterns.py
def handle_event(event):
match event:
case {"type": "click", "element": element, "x": x, "y": y}:
return f"Click on {element} at ({x}, {y})"
case {"type": "keypress", "key": key}:
return f"Key pressed: {key}"
case {"type": "scroll", "direction": direction}:
return f"Scrolled {direction}"
case {"type": unknown_type}:
return f"Unknown event type: {unknown_type}"
case _:
return "Invalid event format"
print(handle_event({"type": "click", "element": "button", "x": 100, "y": 200}))
print(handle_event({"type": "keypress", "key": "Enter"}))
print(handle_event({"type": "scroll", "direction": "down", "amount": 3}))
print(handle_event({"type": "resize"}))
Output:
Click on button at (100, 200)
Key pressed: Enter
Scrolled down
Unknown event type: resize
Mapping patterns only check for the keys you specify — extra keys in the dictionary are ignored. The scroll event dictionary has an amount key that the pattern does not mention, and that is fine. The pattern {"type": unknown_type} matches any dictionary with a "type" key and captures its value. This makes mapping patterns perfect for processing JSON-like data where different message types have different fields.
Matching Class Instances
Class patterns combine type checking and attribute extraction in a single step. Instead of writing isinstance() checks followed by attribute access, you describe the class and the attribute values you expect:
# class_patterns.py
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
radius: float
@dataclass
class Rectangle:
origin: 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=center, radius=r):
return f"Circle at ({center.x}, {center.y}) with radius {r}"
case Rectangle(origin=origin, width=w, height=h) if w == h:
return f"Square at ({origin.x}, {origin.y}) with side {w}"
case Rectangle(origin=origin, width=w, height=h):
return f"Rectangle at ({origin.x}, {origin.y}), {w}x{h}"
case _:
return "Unknown shape"
print(describe_shape(Circle(Point(0, 0), 5)))
print(describe_shape(Circle(Point(3, 4), 2.5)))
print(describe_shape(Rectangle(Point(1, 1), 10, 10)))
print(describe_shape(Rectangle(Point(0, 0), 8, 3)))
Output:
Circle at origin with radius 5
Circle at (3, 4) with radius 2.5
Square at (1, 1) with side 10
Rectangle at (0, 0), 8x3
Notice how the first Circle case uses a nested pattern — it matches a Circle whose center is specifically at the origin Point(x=0, y=0). The Rectangle case uses a guard clause (if w == h) to distinguish squares from regular rectangles. Class patterns work best with dataclasses and named tuples because Python can automatically match keyword arguments to attributes. For regular classes, you would need to define a __match_args__ tuple to enable positional matching.
Adding Guard Clauses
Sometimes the pattern alone is not enough to decide which case should match. Guard clauses add an if condition after the pattern that must also be true for the case to match. The guard can reference any variables captured by the pattern:
# guard_clauses.py
def categorize_score(score):
match score:
case s if s < 0 or s > 100:
return f"Invalid score: {s}"
case s if s >= 90:
return f"{s} -- A grade (excellent)"
case s if s >= 80:
return f"{s} -- B grade (good)"
case s if s >= 70:
return f"{s} -- C grade (average)"
case s if s >= 60:
return f"{s} -- D grade (below average)"
case s:
return f"{s} -- F grade (failing)"
print(categorize_score(95))
print(categorize_score(82))
print(categorize_score(55))
print(categorize_score(-5))
Output:
95 -- A grade (excellent)
82 -- B grade (good)
55 -- F grade (failing)
Invalid score: -5
The pattern s by itself matches any value and binds it to the variable s. The guard if s >= 90 then filters whether this particular case should apply. Guards are evaluated in order, so the invalid score check comes first to reject bad input before the grading logic runs. This is cleaner than having the validation scattered across multiple elif branches.
Combining Patterns with OR
The OR pattern using the pipe | operator lets you match any of several patterns with the same case block. You have already seen this with literals, but it works with more complex patterns too:
# or_patterns.py
def parse_bool(value):
match value:
case True | "true" | "yes" | "1" | 1:
return True
case False | "false" | "no" | "0" | 0:
return False
case None | "":
return None
case _:
raise ValueError(f"Cannot parse {value!r} as boolean")
print(parse_bool("yes"))
print(parse_bool(0))
print(parse_bool("false"))
print(parse_bool(None))
Output:
True
False
False
None
This pattern is extremely useful for building flexible input parsers that need to accept multiple formats for the same logical value. Configuration files, command-line arguments, and API parameters often use different representations for booleans, and a single OR pattern handles all of them in one readable line. Note that when using OR patterns with capture variables, every alternative must bind the same set of variables — Python enforces this at compile time.
Common Pitfalls to Avoid
There are a few tricky behaviors in match-case that catch even experienced Python developers. The most common mistake is accidentally creating a capture pattern when you meant to match a constant:
# pitfalls.py
HTTP_OK = 200
HTTP_NOT_FOUND = 404
status = 500
# WRONG -- this does NOT work as expected
match status:
case HTTP_OK: # This captures 500 into a NEW variable called HTTP_OK!
print("Success")
case HTTP_NOT_FOUND: # This never runs -- the first case caught everything
print("Not found")
# RIGHT -- use literal values or dotted names
print("---")
match status:
case 200:
print("Success")
case 404:
print("Not found")
case other:
print(f"Other status: {other}")
Output:
Success
---
Other status: 500
In the first match block, case HTTP_OK does not compare against the variable HTTP_OK. Instead, it creates a new variable called HTTP_OK that captures whatever the subject value is. This is because bare names in patterns are always capture patterns. To match against constants, use literal values directly, use dotted names like case http.HTTPStatus.OK, or use a guard clause like case status if status == HTTP_OK.
Real-Life Example: Building a CLI Command Parser
Let’s tie everything together with a practical project — a command-line parser that processes structured user commands using every pattern type we have covered:
# cli_parser.py
from dataclasses import dataclass
@dataclass
class Task:
title: str
priority: str = "medium"
done: bool = False
def run_command(command, tasks):
"""Parse and execute a CLI command on a task list."""
parts = command.strip().split()
match parts:
case ["add", *words] if words:
title = " ".join(words)
task = Task(title=title)
tasks.append(task)
return f"Added: '{title}'"
case ["done", index] if index.isdigit():
idx = int(index)
if 0 <= idx < len(tasks):
tasks[idx].done = True
return f"Completed: '{tasks[idx].title}'"
return f"Error: no task at index {idx}"
case ["priority", index, ("high" | "medium" | "low") as level] if index.isdigit():
idx = int(index)
if 0 <= idx < len(tasks):
tasks[idx].priority = level
return f"Set '{tasks[idx].title}' priority to {level}"
return f"Error: no task at index {idx}"
case ["list"]:
if not tasks:
return "No tasks yet"
lines = []
for i, t in enumerate(tasks):
status = "done" if t.done else "todo"
lines.append(f" [{i}] [{status}] [{t.priority}] {t.title}")
return "\n".join(lines)
case ["list", "done"]:
done_tasks = [t for t in tasks if t.done]
if not done_tasks:
return "No completed tasks"
return "\n".join(f" - {t.title}" for t in done_tasks)
case ["list", "pending"]:
pending = [t for t in tasks if not t.done]
if not pending:
return "All tasks complete!"
return "\n".join(f" - {t.title} [{t.priority}]" for t in pending)
case ["quit" | "exit"]:
return "QUIT"
case []:
return "Type a command (add, done, priority, list, quit)"
case _:
return f"Unknown command: {' '.join(parts)}"
# Simulate a session
tasks = []
commands = [
"add Buy groceries",
"add Write unit tests",
"add Deploy to production",
"priority 2 high",
"done 0",
"list",
"list pending",
"quit"
]
for cmd in commands:
print(f"> {cmd}")
result = run_command(cmd, tasks)
print(result)
if result == "QUIT":
break
print()
Output:
> add Buy groceries
Added: 'Buy groceries'
> add Write unit tests
Added: 'Write unit tests'
> add Deploy to production
Added: 'Deploy to production'
> priority 2 high
Set 'Deploy to production' priority to high
> done 0
Completed: 'Buy groceries'
> list
[0] [done] [medium] Buy groceries
[1] [todo] [medium] Write unit tests
[2] [todo] [high] Deploy to production
> list pending
- Write unit tests [medium]
- Deploy to production [high]
> quit
QUIT
This command parser demonstrates several pattern matching features working together. The ["add", *words] pattern uses a star capture for variable-length input. The ["priority", index, ("high" | "medium" | "low") as level] pattern combines sequence matching, an OR pattern for valid values, and an as binding to capture the matched value. Guard clauses validate that numeric arguments are actually digits before conversion. You could extend this by adding commands for removing tasks, searching by keyword, or sorting by priority — each new command is just another case block.
Frequently Asked Questions
What Python version do I need for match-case?
You need Python 3.10 or later. Structural pattern matching was introduced in Python 3.10 as part of PEP 634, PEP 635, and PEP 636. If you try to use match and case on Python 3.9 or earlier, you will get a SyntaxError. Note that match and case are soft keywords — they only have special meaning in the context of the match statement and can still be used as variable names elsewhere in your code.
Is match-case just a switch statement?
No, it is much more powerful. A switch statement (like in C or JavaScript) only compares a value against constants. Python’s match-case can destructure sequences and mappings, bind captured values to variables, match class instances by their attributes, use guard conditions, and combine patterns with OR. The simple literal matching does resemble a switch, but structural pattern matching handles complex data shapes that a switch statement cannot express.
Does match-case fall through like C switch?
No. Python’s match-case executes only the first matching case and then exits the match block. There is no fall-through behavior and no need for a break statement. If you want multiple patterns to execute the same code, combine them with the OR operator | in a single case, such as case "yes" | "y" | "true". This design prevents the common bug in C where a missing break causes unintended fall-through.
Can I use match-case with regular expressions?
Not directly in the pattern itself, but you can use guard clauses with re.match() or re.search(). For example: case str(s) if re.match(r"^\d{3}-\d{4}$", s) matches strings that look like phone numbers. The pattern ensures the value is a string, and the guard applies the regex check. This keeps the pattern readable while letting you use the full power of regular expressions when needed.
How does match-case compare to if-elif for performance?
For simple literal matching, match-case and if-elif chains have similar performance. The CPython implementation does not currently optimize match statements into jump tables or hash lookups. Choose match-case for readability and maintainability, not for speed. The real performance benefit is developer time — pattern matching makes complex branching logic easier to read, debug, and extend, which reduces the time you spend maintaining the code.
Conclusion
You now have a solid understanding of Python’s structural pattern matching — from simple literal matching to destructuring sequences and dictionaries, matching class instances with nested patterns, filtering with guard clauses, and combining alternatives with OR patterns. The key concepts we covered are match and case syntax, the wildcard _ pattern, capture variables, star patterns for variable-length sequences, mapping patterns for dictionaries, class patterns with dataclasses, guard clauses with if, and OR patterns with |.
Try extending the CLI command parser by adding a search command that filters tasks by keyword, or a sort command that reorders tasks by priority. You could also add persistence by saving tasks to a JSON file between sessions. For the complete language specification and advanced features like walrus patterns and positional class matching, check out the official Python documentation on match statements.