Structural pattern matching, introduced in Python 3.10 via the match and case statements, represents a paradigm shift from the traditional if/elif/else chains. It is not merely a “switch-case” statement lifted from other languages; it is a powerful declarative feature designed for destructuring complex data types and matching against patterns of their structure, not just their values. This makes code that handles nested data structures significantly more readable, maintainable, and less error-prone.

The Basic Syntax and Simple Patterns

The match statement evaluates an expression and compares its value to successive patterns given in case blocks. The first pattern that matches executes its associated block. The simplest form matches against literal values.

def http_status_description(status):
    match status:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case 500:
            return "Internal Server Error"
        case _:
            return "Unknown status code"

print(http_status_description(404))  # Output: Not Found
print(http_status_description(301))  # Output: Unknown status code

The underscore _ is the wildcard pattern and acts as the catch-all default case. It must always be placed last, as Python checks cases in order and the wildcard will match anything. It’s crucial to understand that variable names in a pattern are for binding, not for comparison. To match against the value of an existing variable, it must be qualified with a dot (e.g., .RED) or be a constant.

Matching and Destructuring Sequences

The true power of structural pattern matching emerges when working with sequences like lists or tuples. Patterns can mirror the structure of the data, binding variables to parts of it.

def process_command(command):
    match command:
        case ["load", filename]:
            print(f"Loading file: {filename}")
        case ["save", filename]:
            print(f"Saving to file: {filename}")
        case ["exit", *rest]:
            print("Exiting the application.")
        case _:
            print("Invalid command")

process_command(["load", "data.txt"])       # Output: Loading file: data.txt
process_command(["save", "project.zip"])    # Output: Saving to file: project.zip
process_command(["exit", "--force"])        # Output: Exiting the application.
process_command(["delete"])                 # Output: Invalid command

The *rest pattern uses iterable unpacking to capture any number of remaining items in the sequence. This is far more elegant and explicit than checking the length of a list and indexing into it.

Matching Class Structures

Pattern matching integrates deeply with Python’s object system. You can match against classes and bind attributes to variables. This is incredibly useful for processing ASTs, complex configurations, or data received from APIs.

class Point:
    __match_args__ = ('x', 'y')  # Defines the positional order for matching
    def __init__(self, x, y):
        self.x = x
        self.y = y

def locate_point(point):
    match point:
        case Point(0, 0):
            print("Point is at the origin.")
        case Point(0, y):
            print(f"Point is on the Y-axis at y={y}.")
        case Point(x, 0):
            print(f"Point is on the X-axis at x={x}.")
        case Point(x, y):
            print(f"Point is at ({x}, {y}).")
        case _:
            print("Not a point.")

locate_point(Point(0, 0))   # Output: Point is at the origin.
locate_point(Point(0, 5))   # Output: Point is on the Y-axis at y=5.
locate_point(Point(3, 4))   # Output: Point is at (3, 4).

The __match_args__ class variable is essential. It tells the match statement the order of arguments to use for positional pattern matching. Without it, you would have to use keyword patterns like Point(x=0, y=0), which is more verbose.

Using Guards for Complex Logic

A pattern can be refined with an if clause, known as a guard. The case block will only execute if the pattern matches and the guard condition evaluates to True.

def classify_number(value):
    match value:
        case n if n < 0:
            print(f"{n} is negative.")
        case 0:
            print("Value is zero.")
        case n if n > 0:
            print(f"{n} is positive.")

classify_number(-5)  # Output: -5 is negative.
classify_number(0)   # Output: Value is zero.
classify_number(42)  # Output: 42 is positive.

Guards allow you to incorporate complex logic that cannot be expressed through the pattern syntax alone. The variable bound in the pattern (e.g., n) is available for use in the guard condition.

Common Pitfalls and Best Practices

  1. Capture Variable Gotcha: The most common pitfall is unintentional variable capture. A name in a pattern always binds to the matched value; it never refers to an existing variable of the same name. To compare against a variable’s value, use a guard.

    MAX_LOGIN_ATTEMPTS = 3
    
    def handle_login_attempts(attempts):
        match attempts:
            # This will bind ANY value to the name MAX_LOGIN_ATTEMPTS!
            # case MAX_LOGIN_ATTEMPTS:
            #    print("Reached max attempts.")
    
            # Correct approach: use a guard.
            case n if n == MAX_LOGIN_ATTEMPTS:
                print("Reached max attempts.")
            case n if n > MAX_LOGIN_ATTEMPTS:
                print("Account locked.")
            case _:
                print("Continue attempts.")
    
    handle_login_attempts(3)  # Output: Reached max attempts.
    
  2. Exhaustiveness: Unlike some languages, Python’s match does not enforce exhaustiveness. Always include a wildcard case _ to handle unexpected inputs gracefully, unless you are absolutely certain you’ve covered all possibilities. This prevents silent failures.

  3. Performance and Readability: While match is highly optimized, it is not always the fastest solution for simple value checks. Its primary strength is improving code clarity. Use it when the structural aspect is relevant. Don’t force its use for trivial if/else scenarios.

  4. Matching Built-in Types: Be cautious when matching against built-in types like list or dict. Your pattern might match more than intended. For instance, case [x, y]: will match any sequence of length 2, including tuples and strings like “hi”. For stricter type checking, incorporate guards or match against a tuple that wraps the data.