Python Match Statements: Structural Pattern Matching
Before Python 3.10, handling multiple conditional branches meant writing verbose if-elif-else chains. This worked, but became cumbersome when dealing with complex data structures or multiple...
Key Insights
- Python 3.10’s match statement goes far beyond simple switch-case by supporting structural pattern matching, destructuring, and type checking in a single construct
- Pattern matching shines when handling complex data structures like API responses, ASTs, or command parsers where traditional if-elif chains become unwieldy
- Guards, capture patterns, and sequence unpacking make match statements powerful for real-world scenarios, but they’re not always faster than if-elif—use them for clarity, not performance
Introduction to Pattern Matching
Before Python 3.10, handling multiple conditional branches meant writing verbose if-elif-else chains. This worked, but became cumbersome when dealing with complex data structures or multiple conditions:
# Traditional approach
def handle_response(response):
if isinstance(response, dict) and 'error' in response:
return f"Error: {response['error']}"
elif isinstance(response, dict) and 'data' in response:
if isinstance(response['data'], list):
return f"Got {len(response['data'])} items"
else:
return "Got single item"
elif response is None:
return "No response"
else:
return "Unknown response type"
Python 3.10 introduced structural pattern matching through match-case statements. Unlike simple switch-case found in other languages, Python’s implementation supports destructuring, type checking, and complex pattern matching:
# Pattern matching approach
def handle_response(response):
match response:
case {'error': message}:
return f"Error: {message}"
case {'data': [*items]}:
return f"Got {len(items)} items"
case {'data': item}:
return "Got single item"
case None:
return "No response"
case _:
return "Unknown response type"
The difference becomes even more pronounced with nested structures or when you need to extract multiple values simultaneously.
Basic Match-Case Syntax
The fundamental syntax is straightforward. Match statements evaluate an expression and compare it against patterns sequentially until finding a match:
def handle_http_status(status_code):
match status_code:
case 200:
return "OK"
case 201:
return "Created"
case 400:
return "Bad Request"
case 404:
return "Not Found"
case 500 | 502 | 503:
return "Server Error"
case _:
return "Unknown Status"
The pipe operator | creates OR patterns, matching any of the specified values. The wildcard _ pattern matches anything and should always come last—it’s your default case.
Order matters. Python evaluates cases top-to-bottom and stops at the first match:
def categorize_number(n):
match n:
case 0:
return "zero"
case n if n < 0: # Guard clause
return "negative"
case n if n > 100:
return "large positive"
case _:
return "small positive"
Sequence and Mapping Patterns
Pattern matching excels at destructuring sequences and mappings. You can unpack values directly in the pattern:
def process_coordinates(point):
match point:
case (0, 0):
return "Origin"
case (0, y):
return f"On Y-axis at {y}"
case (x, 0):
return f"On X-axis at {x}"
case (x, y):
return f"Point at ({x}, {y})"
case (x, y, z):
return f"3D point at ({x}, {y}, {z})"
The * operator captures remaining elements:
def analyze_list(items):
match items:
case []:
return "Empty list"
case [single]:
return f"Single item: {single}"
case [first, second]:
return f"Pair: {first}, {second}"
case [first, *rest]:
return f"First: {first}, Rest: {rest} ({len(rest)} items)"
Dictionary patterns are particularly useful for API responses or configuration objects:
def handle_api_response(response):
match response:
case {'status': 'success', 'data': data, 'count': count}:
return f"Success: {count} items retrieved"
case {'status': 'success', 'data': data}:
return f"Success: got data"
case {'status': 'error', 'message': msg, 'code': code}:
return f"Error {code}: {msg}"
case {'status': 'error', 'message': msg}:
return f"Error: {msg}"
case _:
return "Invalid response format"
Use **rest to capture remaining dictionary items:
def process_config(config):
match config:
case {'host': host, 'port': port, **options}:
return f"Connect to {host}:{port} with options: {options}"
case {'host': host, **options}:
return f"Connect to {host}:80 with options: {options}"
Class Pattern Matching
Match statements can destructure class instances, which is invaluable for command patterns, AST processing, or working with dataclasses:
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
def calculate_area(shape):
match shape:
case Circle(radius=r):
return 3.14159 * r * r
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return 0.5 * b * h
case _:
raise ValueError("Unknown shape")
You can also use positional patterns if your class defines __match_args__:
@dataclass
class Point:
x: float
y: float
__match_args__ = ('x', 'y')
def describe_point(point):
match point:
case Point(0, 0):
return "Origin"
case Point(x, 0):
return f"On X-axis at {x}"
case Point(0, y):
return f"On Y-axis at {y}"
case Point(x, y):
return f"At ({x}, {y})"
Guards and Advanced Patterns
Guards add conditional logic to patterns using if clauses:
def categorize_transaction(transaction):
match transaction:
case {'amount': amount, 'type': 'debit'} if amount > 1000:
return "Large withdrawal - requires approval"
case {'amount': amount, 'type': 'debit'}:
return f"Debit: ${amount}"
case {'amount': amount, 'type': 'credit'} if amount > 5000:
return "Large deposit - verify source"
case {'amount': amount, 'type': 'credit'}:
return f"Credit: ${amount}"
The as keyword captures matched values while still checking structure:
def process_nested_data(data):
match data:
case {'user': {'name': name, 'email': email} as user_data}:
# user_data contains the entire user dict
return f"User {name} ({email}): {user_data}"
case {'items': [first, *rest] as all_items} if len(all_items) > 10:
return f"Large batch: {len(all_items)} items"
Nested patterns handle complex structures like expression trees:
@dataclass
class BinOp:
op: str
left: any
right: any
@dataclass
class Num:
value: int
def evaluate(expr):
match expr:
case Num(value=v):
return v
case BinOp(op='+', left=l, right=r):
return evaluate(l) + evaluate(r)
case BinOp(op='*', left=l, right=r):
return evaluate(l) * evaluate(r)
case BinOp(op='-', left=l, right=r):
return evaluate(l) - evaluate(r)
Practical Use Cases
Pattern matching shines in command-line parsers:
def execute_command(cmd):
match cmd.split():
case ['quit'] | ['exit']:
return 'QUIT'
case ['help', command]:
return f"Help for: {command}"
case ['help']:
return "Available commands: help, list, get, set"
case ['list', *filters]:
return f"Listing with filters: {filters}"
case ['get', key]:
return f"Getting value for: {key}"
case ['set', key, value]:
return f"Setting {key} = {value}"
case ['set', key, *values]:
return f"Setting {key} = {' '.join(values)}"
case _:
return "Unknown command"
State machines become more readable:
def process_event(state, event):
match (state, event):
case ('idle', 'start'):
return 'running'
case ('running', 'pause'):
return 'paused'
case ('paused', 'resume'):
return 'running'
case ('running' | 'paused', 'stop'):
return 'idle'
case (current, _):
return current # Invalid transition, stay in current state
Best Practices and Gotchas
Use match for structural complexity, not simple equality checks. If you’re just comparing values, if-elif is clearer:
# Don't do this
match status:
case 'active':
return True
case _:
return False
# Do this instead
return status == 'active'
Pattern matching isn’t necessarily faster. It’s optimized for clarity, not performance. Benchmarks show if-elif can be faster for simple cases.
Watch out for mutable default captures:
# Anti-pattern: This doesn't work as expected
def process(data):
match data:
case {'items': items}:
items.append('new') # Modifies original!
Be explicit about types when needed:
def process_value(val):
match val:
case int(x) if x > 0: # Explicitly check type
return f"Positive integer: {x}"
case str(s) if s.isdigit():
return f"Numeric string: {s}"
case _:
return "Other"
Order patterns from specific to general:
# Wrong order
match point:
case (x, y): # Catches everything!
return "2D point"
case (0, 0): # Never reached
return "Origin"
# Correct order
match point:
case (0, 0):
return "Origin"
case (x, y):
return "2D point"
Python’s match statement is a powerful tool for handling complex data structures. Use it when destructuring adds clarity, when handling multiple related conditions, or when working with typed data structures. Avoid it for simple value comparisons where traditional conditionals are more straightforward. The key is recognizing when structural pattern matching makes your intent clearer to readers—and to your future self.