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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.