pyguides

Structural Pattern Matching with match/case

Python 3.10 introduced a powerful new feature: structural pattern matching, commonly known as the match/case statement. If you’ve used switch statements in other languages, you might think you already know what this is. But Python’s implementation goes far beyond simple value switching. It lets you match against data structures, extract values, and handle complex conditional logic with clean, readable syntax.

Why Use match/case?

Before the match statement, you’d handle complex conditional logic with a chain of if/elif/else statements or a dictionary dispatch pattern. Both work, but they become unwieldy as conditions grow. The match statement lets you express intent directly: “match this value against these patterns.”

A match statement evaluates each case clause from top to bottom, stopping at the first one that succeeds. This ordering matters: you should place more specific patterns before more general ones. If a catch-all pattern like _ appears too early, it will swallow every value and the cases below it become dead code. Python’s compiler does not warn about unreachable match arms, so you need to check the ordering yourself.

Consider a function that processes HTTP status codes:

def handle_response(status):
    match status:
        case 400:
            return "Bad request"
        case 401:
            return "Unauthorized"
        case 403:
            return "Forbidden"
        case 404:
            return "Not found"
        case 500:
            return "Internal server error"
        case _:
            return f"Unknown status code: {status}"

The _ wildcard pattern matches anything; it acts like the final else in an if chain, catching any value that didn’t match a more specific case. This guarantee of exhaustive matching means you won’t accidentally let an unexpected value slip through unhandled. In the status code example above, the wildcard returns a formatted string with the code itself, which is a common pattern for logging or returning fallback responses.

Literal Patterns

The simplest form of matching uses literal values. Strings, numbers, and booleans all work as patterns:

def describe_number(n):
    match n:
        case 0:
            return "Zero"
        case 1:
            return "One"
        case 2:
            return "Two"
        case _:
            return f"Something else: {n}"

You can also match multiple literals with the | operator, called the “or pattern.” This groups several alternatives into a single case clause, so you can handle a set of related values without repeating the same code. The example below distinguishes safe HTTP methods from unsafe ones, a common pattern when you need to validate or route requests based on their side-effect profile.

def http_method(method):
    match method.upper():
        case "GET" | "HEAD" | "OPTIONS":
            return "Safe method"
        case "POST" | "PUT" | "PATCH":
            return "Unsafe method"
        case _:
            return "Unknown method"

Literal patterns handle fixed values well, but real-world data often comes in structured containers like lists and dictionaries. The next level of pattern matching is about capturing values from those structures so you can work with the extracted data directly, rather than indexing into containers after confirming their shape.

Capturing Patterns

Sometimes you want to capture the matched value for use in the case body. Use a variable name (without quotes) in the pattern to bind whatever appears in that position:

def process(data):
    match data:
        case [x, y]:
            return f"Got a two-element list: {x}, {y}"
        case {"name": name, "age": age}:
            return f"Got a dict with name={name}, age={age}"
        case _:
            return "Unknown structure"

The variable name without quotes binds whatever value appears at that position in the matched structure. This is what makes structural pattern matching feel declarative: you describe the shape you expect and where you want the data, and Python handles the destructuring for you. Capturing works with lists, tuples, dictionaries, and any object that supports the sequence or mapping protocol.

Class Patterns

One of the most powerful features is matching against class structures. You can match dataclasses, namedtuples, and regular classes with positional or keyword arguments:

from dataclasses import dataclass
from datetime import datetime

@dataclass
class Event:
    timestamp: datetime
    source: str

@dataclass  
class ClickEvent(Event):
    element_id: str

@dataclass
class PageViewEvent(Event):
    path: str
    duration_seconds: int

def process_event(event):
    match event:
        case ClickEvent(timestamp, _, element_id):
            return f"Click on {element_id} at {timestamp}"
        case PageViewEvent(timestamp, _, path, duration):
            return f"Viewed {path} for {duration}s"
        case _:
            return "Unknown event type"

The _ placeholder ignores values you do not need, like the source field in this example. Class patterns match against attributes by position or name, and they work especially well with dataclasses because the field order is well-defined. You can also match against regular classes by implementing __match_args__ to declare which attributes are used for positional matching.

Pattern Guards

Sometimes you need an extra condition beyond the pattern itself. That’s what guards are for: if clauses after your pattern:

def classify_point(point):
    match point:
        case (0, 0):
            return "Origin"
        case (x, 0) if x > 0:
            return f"Positive x-axis: {x}"
        case (0, y) if y > 0:
            return f"Positive y-axis: {y}"
        case (x, y):
            return f"Point at ({x}, {y})"

The guard clause is evaluated only after the pattern succeeds, which means you can use variables captured by the pattern inside the guard expression. Guards let you express conditions that can’t be encoded in the pattern syntax alone; comparisons, membership tests, and arbitrary boolean logic are all fair game. This separation keeps patterns clean while still allowing precise control over which case fires.

Matching with AS

The as pattern lets you both match a pattern and capture the entire match:

def handle_response(response):
    match response:
        case {"status": 200, "data": data} as success:
            return f"Success with data: {data}"
        case {"error": error_msg} as error:
            return f"Error: {error_msg}"
        case _:
            return "Unexpected response format"

The as binding gives you a named reference to the whole matched value while still destructuring its internals. This is useful when you need to log the full input alongside extracted fields, or when the case body needs to pass the complete object to another function. Without as, you would have to reference the original variable from the outer scope and hope it still matches the pattern that just succeeded. In deeply nested match statements, as keeps the right data in scope without ambiguity.

When to Use match/case

The match statement shines when you’re:

  • Processing structured data (JSON, dataclasses, namedtuples)
  • Implementing state machines
  • Handling protocol messages or command parsing
  • Replacing complex if/elif chains that compare against multiple values

For simple value comparisons, a dictionary dispatch or simple if/else might still be clearer. The match statement earns its keep when patterns become complex.

A common anti-pattern is using match where a single if would do. If you are matching a boolean flag or checking one condition with a simple else branch, sticking with if/else keeps the code shorter. Reserve match for when you have three or more branches with structured data. Remember that pattern matching is exhaustive at runtime: if no case matches and there is no wildcard, Python raises a MatchError. Always include a catch-all case unless you are certain every possible input will hit one of your patterns.

See Also

  • python-dataclasses — Create structured data classes that work great with pattern matching
  • error-handling — Python’s exception handling for resilient code