Python's Match Statement Is Actually Good
I ignored match statements for a while. 'Just use if/elif,' I thought. But they're genuinely useful.
When Python 3.10 introduced pattern matching, I was skeptical. It looked like syntactic sugar for switch statements from other languages. But after using it in real projects, I get it now. Match statements aren't just about replacing if/elif chains - they're about expressing intent more clearly and handling complex data structures elegantly.
Basic matching
Let's start simple. Yes, you can use match for basic value checks:
match command:
case 'quit':
exit()
case 'help':
show_help()
case _:
print('Unknown command')
Honestly? This isn't much better than if/elif. The underscore _ is the wildcard that catches everything else, which is cleaner than a final else, but not revolutionary.
Where it shines: destructuring
Here's where match gets interesting. Destructuring lets you unpack data structures and capture values in one go:
match point:
case (0, 0):
print('origin')
case (0, y):
print(f'on y-axis at {y}')
case (x, 0):
print(f'on x-axis at {x}')
case (x, y):
print(f'at ({x}, {y})')
The x and y are captured automatically. Try doing that with if/elif - you'd need to unpack manually and it gets messy fast. This is concise and readable.
Matching dictionaries
This is where I use match most often - handling different shapes of API responses or configuration objects:
match response:
case {'error': message}:
print(f'Error: {message}')
case {'data': data, 'count': count}:
print(f'Got {count} items')
process(data)
case {'data': data}:
process(data)
The pattern matching checks if keys exist AND captures their values. No more if 'error' in response nonsense.
Guards: adding conditions
You can add if conditions to make patterns more specific. These are called guards:
match user_input:
case {'age': age} if age < 18:
print('Access denied: minors not allowed')
case {'age': age} if age >= 18:
print(f'Welcome! Age: {age}')
case _:
print('Invalid input')
Guards let you combine structural matching with value checks. Super useful for validation logic.
Multiple patterns with OR
Use | to match multiple patterns with the same handler:
match command:
case 'quit' | 'exit' | 'q':
exit()
case 'help' | 'h' | '?':
show_help()
Much cleaner than if command in ['quit', 'exit', 'q'] when you're already using match for other cases.
Matching class instances
You can match objects and their attributes in one expression. Here's a practical example with dataclasses:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
radius: float
match shape:
case Circle(center=Point(x=0, y=0), radius=r):
print(f'Circle at origin with radius {r}')
case Circle(center=Point(x=x, y=y), radius=r) if r > 10:
print(f'Large circle at ({x}, {y})')
case Circle(center=c, radius=r):
print(f'Circle with radius {r}')
This combines instance checking, attribute extraction, and conditional logic. The equivalent if/elif code would be verbose and harder to follow.
Real-world example: parsing commands
I built a simple CLI tool where match really cleaned things up:
def handle_command(cmd_parts):
match cmd_parts:
case ['list']:
list_all()
case ['list', category]:
list_by_category(category)
case ['add', name, *tags]:
add_item(name, tags)
case ['delete', item_id] if item_id.isdigit():
delete_item(int(item_id))
case ['help', command]:
show_help_for(command)
case _:
print('Unknown command')
The *tags captures remaining arguments as a list. Try doing that cleanly with if/elif - you'd be checking lengths, slicing lists, and it'd be a mess.
State machines
Pattern matching is perfect for state machine logic:
match (current_state, event):
case ('idle', 'start'):
transition_to('running')
case ('running', 'pause'):
transition_to('paused')
case ('paused', 'resume'):
transition_to('running')
case ('running' | 'paused', 'stop'):
transition_to('idle')
case _:
raise InvalidTransition(current_state, event)
Matching tuples of (state, event) makes the state machine logic crystal clear. Each line is a valid transition.
Processing events
When handling different event types from a message queue or event stream:
match event:
case {'type': 'user.signup', 'email': email, 'plan': 'premium'}:
create_premium_user(email)
send_welcome_email(email, premium=True)
case {'type': 'user.signup', 'email': email}:
create_free_user(email)
send_welcome_email(email, premium=False)
case {'type': 'payment.success', 'user_id': uid, 'amount': amt}:
process_payment(uid, amt)
case {'type': 'payment.failed', 'user_id': uid}:
notify_payment_failure(uid)
Each case clearly shows what event structure it expects. Much better than nested if statements checking event types and fields.
Sequences and wildcards
Match really shines when working with sequences of varying lengths:
match items:
case []:
print('Empty list')
case [single]:
print(f'One item: {single}')
case [first, second]:
print(f'Two items: {first}, {second}')
case [first, *rest]:
print(f'Multiple items: first is {first}, got {len(rest)} more')
The *rest syntax is particularly powerful for handling variable-length sequences. This comes up all the time when parsing command-line arguments, processing lists of unknown length, or handling API responses with optional fields.
Here's a real example from a log processor I wrote:
def parse_log_line(parts):
match parts:
case [timestamp, level, message]:
return {'timestamp': timestamp, 'level': level, 'message': message}
case [timestamp, level, message, *context]:
return {
'timestamp': timestamp,
'level': level,
'message': message,
'context': dict(zip(context[::2], context[1::2]))
}
case _:
return {'error': 'Invalid log format'}
Nested patterns
You can nest patterns arbitrarily deep, which is incredibly useful for complex data structures:
match config:
case {
'database': {
'host': host,
'port': port,
'credentials': {'username': user, 'password': pwd}
}
}:
connect_db(host, port, user, pwd)
case {'database': {'host': host}}:
connect_db(host, default_port, default_user, default_pwd)
case _:
raise ConfigError('Invalid database configuration')
This beats the hell out of doing nested get() calls with default values or try/except blocks for missing keys. The pattern itself documents what structure you expect.
I used this extensively when building a deployment tool that read YAML configs. The match statements made it obvious what configuration shapes were valid, and the captured variables were ready to use immediately.
Type matching with isinstance patterns
Match can check types while capturing values:
def process_value(val):
match val:
case int(x) if x < 0:
return abs(x)
case int(x):
return x * 2
case str(s) if s.isdigit():
return int(s) * 2
case str(s):
return s.upper()
case list(items):
return [process_value(item) for item in items]
case _:
return None
This combines type checking, value extraction, and transformation logic. The equivalent code with isinstance checks and intermediate variables would be much longer and harder to follow.
Literal patterns and constants
You can match against constants and use them with other patterns:
from http import HTTPStatus
match response.status_code:
case HTTPStatus.OK:
return response.json()
case HTTPStatus.NOT_FOUND:
raise NotFoundError()
case HTTPStatus.UNAUTHORIZED | HTTPStatus.FORBIDDEN:
raise AuthError()
case code if 400 <= code < 500:
raise ClientError(code)
case code if 500 <= code < 600:
raise ServerError(code)
case _:
raise UnknownError(response.status_code)
Using named constants (like HTTPStatus) makes the code self-documenting and less error-prone than magic numbers.
Common gotchas and how to avoid them
Gotcha 1: Order matters
Match statements check cases in order and stop at the first match. This can bite you:
# Bug: the second case never matches
match point:
case (x, y):
print(f'Generic point: ({x}, {y})')
case (0, 0):
print('Origin') # Never reached!
# Fixed: more specific cases first
match point:
case (0, 0):
print('Origin')
case (x, y):
print(f'Generic point: ({x}, {y})')
Always put more specific patterns before more general ones. Think of it like exception handling - you catch specific exceptions before generic ones.
Gotcha 2: Mutable default captures
If you're matching against mutable objects, remember that the captured value is a reference:
match data:
case {'items': items}:
items.append('new') # Modifies the original!
If you need to avoid this, make a copy:
match data:
case {'items': items}:
items = items.copy()
items.append('new') # Safe now
Gotcha 3: Dictionary matching is partial
Dictionary patterns match if the keys exist - extra keys are ignored:
match {'name': 'Alice', 'age': 30, 'city': 'NYC'}:
case {'name': n}:
print(n) # Matches! age and city are ignored
This is usually what you want, but be aware of it. If you need exact matching, you'll need to add guards or check explicitly.
Gotcha 4: Can't use variable comparisons directly
This won't work:
threshold = 10
match value:
case threshold: # Bug: creates new binding, doesn't compare!
print('At threshold')
Use a guard instead:
threshold = 10
match value:
case x if x == threshold:
print('At threshold')
Or use literal patterns for constants:
MAX_SIZE = 100 # Constants work fine
match size:
case MAX_SIZE:
print('Maximum size reached')
Performance considerations
Match statements are generally as fast as if/elif chains for simple cases, but can be faster for complex structural matching because the interpreter can optimize the pattern matching.
I benchmarked this with a parser that processed thousands of JSON events:
# Using if/elif: ~2.3 seconds for 100k events
def process_if_elif(event):
if 'error' in event:
return handle_error(event['error'])
elif 'data' in event and 'count' in event:
return handle_counted_data(event['data'], event['count'])
elif 'data' in event:
return handle_data(event['data'])
# Using match: ~2.1 seconds for 100k events
def process_match(event):
match event:
case {'error': msg}:
return handle_error(msg)
case {'data': data, 'count': count}:
return handle_counted_data(data, count)
case {'data': data}:
return handle_data(data)
The performance difference is small, but match was consistently faster and the code was more readable. Win-win.
Combining match with other Python features
With dataclasses
Match and dataclasses are a perfect pair:
from dataclasses import dataclass
from enum import Enum
class Status(Enum):
PENDING = 'pending'
PROCESSING = 'processing'
COMPLETED = 'completed'
FAILED = 'failed'
@dataclass
class Task:
id: int
status: Status
retries: int = 0
def handle_task(task):
match task:
case Task(status=Status.FAILED, retries=r) if r < 3:
retry_task(task)
case Task(status=Status.FAILED):
mark_permanently_failed(task)
case Task(status=Status.COMPLETED, id=task_id):
cleanup_task(task_id)
case Task(status=Status.PROCESSING):
monitor_task(task)
With Enums
Enums and match make state handling crystal clear:
from enum import Enum, auto
class ConnectionState(Enum):
DISCONNECTED = auto()
CONNECTING = auto()
CONNECTED = auto()
ERROR = auto()
def handle_connection_event(state, event):
match (state, event):
case (ConnectionState.DISCONNECTED, 'connect'):
return ConnectionState.CONNECTING
case (ConnectionState.CONNECTING, 'success'):
return ConnectionState.CONNECTED
case (ConnectionState.CONNECTING, 'failure'):
return ConnectionState.ERROR
case (ConnectionState.CONNECTED, 'disconnect'):
return ConnectionState.DISCONNECTED
case (ConnectionState.ERROR, 'retry'):
return ConnectionState.CONNECTING
case _:
return state # Invalid transition, stay in current state
With TypedDict
For API responses and structured data:
from typing import TypedDict
class UserResponse(TypedDict):
id: int
name: str
email: str
class ErrorResponse(TypedDict):
error: str
code: int
def handle_api_response(response):
match response:
case {'error': msg, 'code': code}:
raise APIError(f'Error {code}: {msg}')
case {'id': uid, 'name': name, 'email': email}:
return User(uid, name, email)
case _:
raise ValueError('Unexpected response format')
When NOT to use match
Don't reach for match for simple value checks:
# Don't do this
match is_admin:
case True:
grant_access()
case False:
deny_access()
# Just use if
if is_admin:
grant_access()
else:
deny_access()
Also avoid it when you just need membership testing:
# Don't do this
match status:
case 'active':
return True
case 'pending':
return True
case _:
return False
# Just use in
return status in ['active', 'pending']
And definitely don't use it for simple range checks:
# Don't do this
match age:
case a if a < 13:
return 'child'
case a if a < 20:
return 'teen'
case a if a < 65:
return 'adult'
case _:
return 'senior'
# Just use if/elif
if age < 13:
return 'child'
elif age < 20:
return 'teen'
elif age < 65:
return 'adult'
else:
return 'senior'
Match is for structural patterns, not replacing every conditional.
Real-world case study: HTTP request router
Here's a practical example from a microservice I built. Match made the routing logic incredibly clean:
def route_request(method, path, body):
match (method, path.split('/'), body):
case ('GET', ['api', 'users']):
return list_users()
case ('GET', ['api', 'users', user_id]):
return get_user(user_id)
case ('POST', ['api', 'users'], {'name': name, 'email': email}):
return create_user(name, email)
case ('PUT', ['api', 'users', user_id], {'name': name}):
return update_user(user_id, name=name)
case ('DELETE', ['api', 'users', user_id]):
return delete_user(user_id)
case ('GET', ['api', 'users', user_id, 'posts']):
return get_user_posts(user_id)
case ('POST', ['api', 'users', user_id, 'posts'], {'title': t, 'content': c}):
return create_post(user_id, t, c)
case _:
return {'error': 'Route not found'}, 404
This handles method checking, path parsing, and request body validation in one clean block. The equivalent code with if/elif would be at least twice as long and much harder to maintain.
Migration strategy: when to refactor to match
If you have existing if/elif chains, here are signs you should consider refactoring to match:
- You're checking the same data structure in multiple conditions
- You're unpacking tuples or dictionaries and checking their contents
- You have nested if statements checking object types and attributes
- Your code has comments explaining what structure is expected
- You're using isinstance checks followed by attribute access
Before refactoring, make sure you have good test coverage. Match changes how patterns are evaluated, and subtle bugs can slip in during migration.
Debugging match statements
When a match statement isn't behaving as expected, add a catch-all case that prints the value:
match event:
case {'type': 'user.signup', 'email': email}:
handle_signup(email)
case {'type': 'user.login', 'email': email}:
handle_login(email)
case x:
print(f'Unmatched event: {x}') # Debug what's falling through
raise ValueError(f'Unknown event: {x}')
Or use logging for production code:
import logging
match event:
case {'type': 'user.signup', 'email': email}:
handle_signup(email)
case {'type': 'user.login', 'email': email}:
handle_login(email)
case unmatched:
logging.warning(f'Unmatched event: {unmatched}')
Match statements in Python's future
Pattern matching is actively being improved. PEP 636 introduced the syntax in Python 3.10, but the community is discussing enhancements like:
- Better type integration with type checkers
- Exhaustiveness checking (compile-time warnings for unhandled cases)
- More concise syntax for common patterns
- Better integration with protocols and structural typing
The feature is here to stay and will likely get better over time. Learning it now means you'll be ahead of the curve as the Python ecosystem adopts it more widely.
Compared to other languages
If you've used pattern matching in Rust, Scala, or Haskell, Python's version will feel familiar but less powerful. It doesn't have Rust's exhaustiveness checking (the compiler won't warn you about unhandled cases). It's not as concise as Scala's syntax. But for Python, it's a solid addition that fits the language well.
The key insight: match statements make complex conditional logic more declarative. Instead of imperatively checking conditions and extracting values, you declare the patterns you expect and let Python handle the matching and extraction. When you're dealing with varied data structures, nested objects, or state-based logic, that shift in perspective really helps.
Use match when the structure of your data matters. Use if/elif when you're just checking values. Once you internalize that distinction, you'll know exactly when to reach for it.