Python Match Case: Structural Pattern Matching Explained (Python 3.10+)
Updated on
Python developers have relied on if-elif chains for decades to handle branching logic. Checking a variable against multiple values, inspecting the structure of data, or routing based on object types all require verbose conditional blocks that grow harder to read as cases multiply. A function that dispatches on ten possible command types becomes a wall of elif statements where the actual logic is buried under repetitive comparisons.
This is not just a readability problem. Deep if-elif chains are prone to subtle bugs -- a misplaced condition, a forgotten case, or an accidental fall-through that silently passes the wrong data downstream. Other languages solved this years ago with pattern matching constructs, but Python lacked a native solution until version 3.10.
Python 3.10 introduced the match-case statement (PEP 634), bringing structural pattern matching to the language. It goes far beyond simple value comparison: you can destructure sequences, match dictionary shapes, bind variables, apply guard conditions, and dispatch on class types -- all in a clean, declarative syntax. This guide covers every pattern type with practical examples.
What Is Structural Pattern Matching?
Structural pattern matching lets you compare a value (the "subject") against a series of patterns and execute the code block of the first matching pattern. Unlike a switch statement in C or Java, Python's match-case does not just compare values -- it inspects the structure of data.
The basic syntax:
match subject:
case pattern1:
# code for pattern1
case pattern2:
# code for pattern2
case _:
# default case (wildcard)Key characteristics:
- Patterns are evaluated top to bottom. The first matching pattern wins.
- The wildcard pattern
_matches anything and serves as the default case. - Patterns can bind variables, meaning parts of the matched value are captured into names you can use in the case block.
- No fall-through behavior. Only one case block executes.
Python version requirement: match-case requires Python 3.10 or later. Check your version with python --version. If you are running an older version, you need to upgrade before using this feature.
Literal Patterns: Matching Exact Values
The simplest use of match-case compares a value against literal constants:
def get_http_status_message(code):
match code:
case 200:
return "OK"
case 201:
return "Created"
case 301:
return "Moved Permanently"
case 400:
return "Bad Request"
case 403:
return "Forbidden"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
case _:
return f"Unknown status code: {code}"
print(get_http_status_message(404)) # Not Found
print(get_http_status_message(418)) # Unknown status code: 418Literal patterns work with integers, strings, booleans, and None:
def describe_value(value):
match value:
case True:
return "Boolean true"
case False:
return "Boolean false"
case None:
return "None value"
case "":
return "Empty string"
case 0:
return "Zero"
case _:
return f"Other: {value}"Important: True, False, and None are matched by identity (like is), not by equality. This means match 1 will not match case True even though 1 == True is True in Python.
Or-Patterns: Matching Multiple Values
Use the pipe operator | to match any of several patterns in a single case:
def classify_day(day):
match day.lower():
case "monday" | "tuesday" | "wednesday" | "thursday" | "friday":
return "Weekday"
case "saturday" | "sunday":
return "Weekend"
case _:
return "Invalid day"
print(classify_day("Saturday")) # Weekend
print(classify_day("Wednesday")) # WeekdayOr-patterns work with any pattern type, not just literals:
def categorize_error(code):
match code:
case 400 | 401 | 403 | 404 | 405:
return "Client error"
case 500 | 502 | 503 | 504:
return "Server error"
case _:
return "Other"Variable Capture Patterns
Patterns can capture parts of the matched value into variables. A bare name in a pattern acts as a variable that binds to whatever value appears in that position:
def parse_command(command):
match command.split():
case ["quit"]:
return "Exiting program"
case ["hello", name]:
return f"Hello, {name}!"
case ["add", x, y]:
return f"Sum: {int(x) + int(y)}"
case _:
return "Unknown command"
print(parse_command("hello Alice")) # Hello, Alice!
print(parse_command("add 3 5")) # Sum: 8
print(parse_command("quit")) # Exiting programIn case ["hello", name], the variable name captures whatever string appears in the second position of the list. This is fundamentally different from literal matching -- there is no variable called name being compared against. Instead, the pattern creates a new binding.
The Capture vs Constant Pitfall
Since bare names are capture patterns, you cannot directly match against a variable:
QUIT_COMMAND = "quit"
match user_input:
case QUIT_COMMAND: # This captures into QUIT_COMMAND, not compare!
print("This always matches!")To match against a constant, use dotted names or literal values:
class Commands:
QUIT = "quit"
HELP = "help"
match user_input:
case Commands.QUIT: # Dotted name: compares against the value
print("Quitting")
case Commands.HELP:
print("Showing help")Dotted names (containing a .) are treated as value lookups, not capture patterns. This is an intentional design decision in PEP 634.
Sequence Patterns: Destructuring Lists and Tuples
Match-case excels at destructuring sequences. You can match the length and contents of lists or tuples:
def process_point(point):
match point:
case (0, 0):
return "Origin"
case (x, 0):
return f"On x-axis at {x}"
case (0, y):
return f"On y-axis at {y}"
case (x, y):
return f"Point at ({x}, {y})"
case _:
return "Not a valid point"
print(process_point((0, 0))) # Origin
print(process_point((5, 0))) # On x-axis at 5
print(process_point((3, 7))) # Point at (3, 7)Star Patterns for Variable-Length Sequences
Use * to match sequences of variable length:
def analyze_sequence(seq):
match seq:
case []:
return "Empty sequence"
case [single]:
return f"Single element: {single}"
case [first, second]:
return f"Pair: {first}, {second}"
case [first, *middle, last]:
return f"First: {first}, Last: {last}, Middle has {len(middle)} items"
print(analyze_sequence([])) # Empty sequence
print(analyze_sequence([42])) # Single element: 42
print(analyze_sequence([1, 2, 3, 4, 5])) # First: 1, Last: 5, Middle has 3 itemsNested Sequence Patterns
Patterns can be nested to match complex data structures:
def process_matrix_row(row):
match row:
case [[a, b], [c, d]]:
return f"2x2 block: {a}, {b}, {c}, {d}"
case [first_row, *rest]:
return f"First row: {first_row}, remaining rows: {len(rest)}"
print(process_matrix_row([[1, 2], [3, 4]]))
# 2x2 block: 1, 2, 3, 4Mapping Patterns: Matching Dictionaries
Mapping patterns match dictionary-like objects by checking for specific keys and optionally capturing their values:
def handle_api_response(response):
match response:
case {"status": "success", "data": data}:
return f"Success! Data: {data}"
case {"status": "error", "message": msg}:
return f"Error: {msg}"
case {"status": "error", "code": code, "message": msg}:
return f"Error {code}: {msg}"
case {"status": status}:
return f"Unknown status: {status}"
case _:
return "Invalid response format"
print(handle_api_response({"status": "success", "data": [1, 2, 3]}))
# Success! Data: [1, 2, 3]
print(handle_api_response({"status": "error", "message": "Not found"}))
# Error: Not foundMapping patterns match if the dictionary contains the specified keys -- extra keys are ignored. This makes them ideal for working with JSON data and API responses.
Use **rest to capture remaining keys:
def extract_config(config):
match config:
case {"host": host, "port": port, **rest}:
return f"Server: {host}:{port}, extra config: {rest}"
case _:
return "Missing required config"
print(extract_config({"host": "localhost", "port": 8080, "debug": True}))
# Server: localhost:8080, extra config: {'debug': True}Guard Clauses: Adding Conditions to Patterns
Guards add an if condition that must be true for the pattern to match. The pattern is checked first; if it matches, the guard expression is evaluated:
def classify_number(n):
match n:
case x if x < 0:
return "Negative"
case 0:
return "Zero"
case x if x % 2 == 0:
return "Positive even"
case x if x % 2 == 1:
return "Positive odd"
print(classify_number(-5)) # Negative
print(classify_number(0)) # Zero
print(classify_number(4)) # Positive even
print(classify_number(7)) # Positive oddGuards are essential when the pattern alone cannot express the full matching condition:
def validate_age(data):
match data:
case {"name": name, "age": age} if age < 0:
return f"Invalid: {name} has negative age"
case {"name": name, "age": age} if age < 18:
return f"{name} is a minor (age {age})"
case {"name": name, "age": age} if age >= 18:
return f"{name} is an adult (age {age})"
case _:
return "Invalid data format"
print(validate_age({"name": "Alice", "age": 25}))
# Alice is an adult (age 25)Class Patterns: Matching Object Types
Class patterns check if a value is an instance of a class and optionally destructure its attributes:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
radius: float
@dataclass
class Rectangle:
top_left: 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) if r > 100:
return f"Large circle at ({center.x}, {center.y})"
case Circle(center=center, radius=r):
return f"Circle at ({center.x}, {center.y}) with radius {r}"
case Rectangle(width=w, height=h) if w == h:
return f"Square with side {w}"
case Rectangle(top_left=tl, width=w, height=h):
return f"Rectangle at ({tl.x}, {tl.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), 10, 10)))
# Square with side 10For classes without __match_args__, use keyword arguments. Dataclasses and named tuples automatically set __match_args__, enabling positional patterns:
@dataclass
class Color:
r: int
g: int
b: int
def describe_color(color):
match color:
case Color(0, 0, 0):
return "Black"
case Color(255, 255, 255):
return "White"
case Color(r, 0, 0):
return f"Red shade (r={r})"
case Color(0, g, 0):
return f"Green shade (g={g})"
case Color(0, 0, b):
return f"Blue shade (b={b})"
case Color(r, g, b):
return f"RGB({r}, {g}, {b})"
print(describe_color(Color(255, 0, 0))) # Red shade (r=255)
print(describe_color(Color(128, 64, 32))) # RGB(128, 64, 32)Matching Built-in Types
You can match against built-in types for type checking:
def process_value(value):
match value:
case int(n):
return f"Integer: {n}"
case float(n):
return f"Float: {n}"
case str(s):
return f"String: '{s}'"
case list(items):
return f"List with {len(items)} items"
case dict(d):
return f"Dict with keys: {list(d.keys())}"
case _:
return f"Other type: {type(value).__name__}"
print(process_value(42)) # Integer: 42
print(process_value("hello")) # String: 'hello'
print(process_value([1, 2, 3])) # List with 3 itemsMatch Case vs if-elif: When to Use Each
| Criteria | match-case | if-elif |
|---|---|---|
| Python version | 3.10+ only | All versions |
| Value comparison | Clean, one value per case | Verbose with repeated variable |
| Destructuring | Built-in (sequences, dicts, objects) | Manual unpacking required |
| Type checking | Native class patterns | Requires isinstance() calls |
| Guard conditions | if after pattern | Standard if/elif |
| Variable binding | Automatic capture | Manual assignment |
| Performance | Optimized pattern dispatch | Sequential evaluation |
| Readability | Better for 4+ cases with structure | Better for 1-3 simple conditions |
| Fall-through | No (by design) | Possible with missing elif |
When match-case is clearly better:
# Parsing structured data -- match-case
def handle_event(event):
match event:
case {"type": "click", "x": x, "y": y}:
process_click(x, y)
case {"type": "keypress", "key": key, "modifiers": [*mods]}:
process_key(key, mods)
case {"type": "scroll", "delta": delta} if delta != 0:
process_scroll(delta)The equivalent with if-elif:
# Same logic with if-elif -- more verbose
def handle_event(event):
if event.get("type") == "click" and "x" in event and "y" in event:
process_click(event["x"], event["y"])
elif event.get("type") == "keypress" and "key" in event and "modifiers" in event:
process_key(event["key"], event["modifiers"])
elif event.get("type") == "scroll" and event.get("delta", 0) != 0:
process_scroll(event["delta"])When if-elif is still better:
# Simple range checks -- if-elif is clearer
if temperature > 100:
status = "boiling"
elif temperature > 50:
status = "hot"
elif temperature > 20:
status = "warm"
else:
status = "cold"Match-case does not support range comparisons directly in patterns. You would need guards for every case, which negates the readability advantage.
Real-World Use Cases
Command-Line Argument Parsing
import sys
def main():
match sys.argv[1:]:
case ["help"]:
print("Available commands: help, version, run, test")
case ["version"]:
print("v1.0.0")
case ["run", filename]:
print(f"Running {filename}")
case ["run", filename, "--verbose"]:
print(f"Running {filename} with verbose output")
case ["test", *test_files] if test_files:
print(f"Testing: {', '.join(test_files)}")
case ["test"]:
print("Running all tests")
case [unknown, *_]:
print(f"Unknown command: {unknown}")
case []:
print("No command provided. Use 'help' for usage.")State Machine Implementation
from dataclasses import dataclass
from typing import Optional
@dataclass
class State:
name: str
data: Optional[dict] = None
def transition(state, event):
match (state.name, event):
case ("idle", "start"):
return State("loading", {"progress": 0})
case ("loading", "progress"):
progress = state.data.get("progress", 0) + 25
if progress >= 100:
return State("ready", {"progress": 100})
return State("loading", {"progress": progress})
case ("loading", "cancel"):
return State("idle")
case ("ready", "process"):
return State("processing", state.data)
case ("processing", "complete"):
return State("done", {"result": "success"})
case ("processing", "error"):
return State("error", {"message": "Processing failed"})
case (_, "reset"):
return State("idle")
case (current, unknown_event):
print(f"No transition from '{current}' on '{unknown_event}'")
return state
# Usage
state = State("idle")
for event in ["start", "progress", "progress", "progress", "progress", "process", "complete"]:
state = transition(state, event)
print(f"Event: {event} -> State: {state.name}, Data: {state.data}")JSON API Response Handler
def process_api_result(response):
match response:
case {"data": {"users": [*users]}, "meta": {"total": total}}:
print(f"Found {total} users, received {len(users)}")
for user in users:
match user:
case {"name": name, "role": "admin"}:
print(f" Admin: {name}")
case {"name": name, "role": role}:
print(f" {role.title()}: {name}")
case {"data": {"users": []}}:
print("No users found")
case {"error": {"code": code, "message": msg}} if code >= 500:
print(f"Server error ({code}): {msg}")
raise RuntimeError(msg)
case {"error": {"code": code, "message": msg}}:
print(f"Client error ({code}): {msg}")
case _:
print(f"Unexpected response format: {type(response)}")
# Example
process_api_result({
"data": {"users": [
{"name": "Alice", "role": "admin"},
{"name": "Bob", "role": "editor"}
]},
"meta": {"total": 2}
})Configuration File Parser
def apply_config(settings):
for key, value in settings.items():
match (key, value):
case ("database", {"host": host, "port": int(port), "name": db}):
print(f"DB connection: {host}:{port}/{db}")
case ("database", _):
raise ValueError("Invalid database config: need host, port, name")
case ("logging", {"level": level}) if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
print(f"Log level set to {level}")
case ("logging", {"level": level}):
raise ValueError(f"Invalid log level: {level}")
case ("features", {"enabled": list(features)}):
print(f"Enabled features: {', '.join(features)}")
case ("features", {"enabled": _}):
raise TypeError("Features must be a list")
case (key, _):
print(f"Unknown config key: {key}")
apply_config({
"database": {"host": "localhost", "port": 5432, "name": "mydb"},
"logging": {"level": "INFO"},
"features": {"enabled": ["auth", "cache", "metrics"]}
})Data Transformation Pipeline
def transform_record(record):
"""Transform raw data records into standardized format."""
match record:
case {"timestamp": ts, "value": float(v) | int(v), "unit": str(unit)}:
return {"time": ts, "measurement": float(v), "unit": unit.lower()}
case {"timestamp": ts, "value": str(v), "unit": str(unit)}:
try:
return {"time": ts, "measurement": float(v), "unit": unit.lower()}
except ValueError:
return {"time": ts, "measurement": None, "unit": unit.lower(), "error": f"Cannot parse: {v}"}
case {"timestamp": ts, "value": v}:
return {"time": ts, "measurement": float(v), "unit": "unknown"}
case {"values": [*values]} if values:
return [transform_record({"timestamp": None, "value": v, "unit": "batch"}) for v in values]
case _:
return {"error": f"Unrecognized format: {record}"}
# Examples
print(transform_record({"timestamp": "2026-01-01", "value": 42.5, "unit": "Celsius"}))
print(transform_record({"timestamp": "2026-01-01", "value": "98.6", "unit": "F"}))Advanced Pattern Techniques
AS Pattern: Binding the Whole Match
Use as to capture the entire matched value while also destructuring it:
def process_item(item):
match item:
case {"name": str(name), "price": float(price)} as product if price > 100:
print(f"Premium product: {product}")
case {"name": str(name), "price": float(price)} as product:
print(f"Standard product: {product}")Combining Pattern Types
Patterns compose naturally. You can mix sequence, mapping, and class patterns:
@dataclass
class Order:
items: list
customer: dict
def process_order(order):
match order:
case Order(items=[single_item], customer={"vip": True}):
print(f"VIP single-item order: {single_item}")
case Order(items=[_, _, *rest], customer={"name": name}) if len(rest) > 0:
print(f"{name} ordered {2 + len(rest)} items")
case Order(items=[], customer=_):
print("Empty order")Using Match-Case in Jupyter Notebooks
Pattern matching is particularly useful in data analysis workflows where you need to handle different data formats or API responses. When exploring datasets interactively in Jupyter notebooks, match-case provides a clean way to handle the variety of data shapes you encounter.
def classify_cell_output(output):
"""Classify Jupyter cell output by type."""
match output:
case {"output_type": "stream", "text": text}:
return f"Text output: {len(text)} chars"
case {"output_type": "error", "ename": name, "evalue": value}:
return f"Error: {name}: {value}"
case {"output_type": "display_data", "data": {"image/png": _}}:
return "Image output"
case {"output_type": "execute_result", "data": {"text/html": html}}:
return "HTML table output"
case _:
return "Unknown output type"For data scientists who want AI assistance while working with pattern matching and other Python 3.10+ features in notebooks, RunCell (opens in a new tab) provides an AI agent directly inside Jupyter that can help write, debug, and optimize match-case statements based on your actual data structures.
Common Mistakes and How to Avoid Them
Mistake 1: Using match-case on Python < 3.10
# This fails with SyntaxError on Python 3.9 and earlier
match value:
case 1:
print("one")Fix: Check sys.version_info >= (3, 10) or upgrade Python.
Mistake 2: Treating case names as comparisons
expected = 200
match status_code:
case expected: # WRONG: creates a new variable 'expected'
print("OK")Fix: Use a dotted name, a literal, or a guard:
match status_code:
case code if code == expected:
print("OK")Mistake 3: Forgetting the wildcard case
Without case _, unmatched values silently do nothing:
match command:
case "start":
run()
case "stop":
halt()
# If command is "pause", nothing happens -- no error, no warningFix: Always include a wildcard case for explicit handling:
match command:
case "start":
run()
case "stop":
halt()
case _:
raise ValueError(f"Unknown command: {command}")Mistake 4: Order-dependent patterns without specificity
Patterns are checked top to bottom. A broad pattern before a specific one shadows it:
# WRONG: the first case always matches
match point:
case (x, y): # Captures everything
print("generic")
case (0, 0): # Never reached
print("origin")Fix: Put specific patterns first:
match point:
case (0, 0):
print("origin")
case (x, y):
print(f"point at ({x}, {y})")Performance Considerations
Match-case is compiled to efficient bytecode. For simple literal matching, the Python compiler optimizes it similarly to dictionary lookups. For structural patterns, it generates a decision tree that avoids redundant checks.
Benchmarks show that match-case performs comparably to if-elif chains for small numbers of cases (under 5). For larger dispatch tables (10+ cases), match-case can be faster due to bytecode optimization, especially with literal patterns.
However, pattern matching is not a replacement for dictionary dispatch when you simply need to map values to functions:
# For simple value-to-action mapping, a dict is cleaner and faster
handlers = {
"click": handle_click,
"scroll": handle_scroll,
"keypress": handle_keypress,
}
handler = handlers.get(event_type, handle_unknown)
handler(event_data)Use match-case when you need structural inspection, destructuring, or guard conditions -- things a dictionary lookup cannot express.
FAQ
What Python version do I need for match-case?
Python 3.10 or later is required for the match-case statement. It was introduced in PEP 634 as part of the Python 3.10 release in October 2021. Attempting to use match-case on Python 3.9 or earlier results in a SyntaxError. You can check your version by running python --version in your terminal.
Is Python match-case the same as switch-case in other languages?
No. Python's match-case is structural pattern matching, which is more powerful than a traditional switch-case. While switch-case in C or Java only compares values, Python's match-case can destructure sequences and dictionaries, match class instances by attributes, bind variables from matched data, and apply guard conditions. It is closer to pattern matching in Rust, Scala, or Haskell.
Does match-case have fall-through like C switch?
No. Python's match-case executes only the first matching case block, then exits the match statement. There is no fall-through behavior and no need for break statements. If you want multiple patterns to run the same code, use or-patterns with the pipe operator: case "a" | "b" | "c": will match any of those values.
Can I use match-case with regular expressions?
Not directly. Patterns in match-case are structural, not regex-based. However, you can use guard clauses to apply regex matching: case str(s) if re.match(r"pattern", s): combines structural type checking with regex validation in the guard.
How does match-case handle None values?
None is matched as a literal pattern using case None:. Since None is a singleton in Python, the match uses identity comparison (equivalent to is None). This means case None: will only match the actual None object, not other falsy values like 0, False, or empty strings.
What happens if no case matches?
If no case matches and there is no wildcard pattern (case _:), the match statement silently does nothing -- execution continues after the match block. No exception is raised. This is why it is good practice to always include a wildcard case, either to provide a default action or to raise an exception for unexpected values.
Conclusion
Python's match-case statement brings structural pattern matching to a language that has long relied on if-elif chains for complex branching logic. It provides a declarative way to inspect data shapes, destructure sequences and dictionaries, dispatch on object types, and bind variables -- all in a syntax that reads more clearly than the equivalent conditional code.
The key insight is that match-case is not just a switch statement. It is a tool for working with structured data: parsing commands, handling API responses, implementing state machines, and routing based on object types. When your branching logic involves checking the shape of data rather than simple value comparisons, match-case produces code that is both shorter and easier to maintain.
Start with simple literal patterns, then gradually incorporate sequence destructuring, mapping patterns, and class patterns as your use cases demand. Always include a wildcard case for explicit handling of unexpected inputs, put specific patterns before general ones, and remember that bare names capture values rather than compare against them.
For interactive exploration of match-case patterns with your own data, Jupyter notebooks provide an ideal environment for testing patterns incrementally. RunCell (opens in a new tab) enhances this workflow with AI assistance that understands Python 3.10+ features and can suggest pattern structures based on your data shapes.