Structural Pattern Matching in Python — Deep Dive

Technical perspective

Structural pattern matching (PEPs 634–636) is the most significant syntax addition to Python since async/await. Under the surface, it compiles to a decision tree of type checks, length checks, and attribute accesses — no hash tables, no __eq__ calls for class patterns. Understanding this compilation model helps you write patterns that are both readable and efficient.

The compilation model

CPython compiles match statements into a sequence of bytecode operations that form a decision tree. Each pattern compiles to:

  1. Type checkMATCH_CLASS or isinstance equivalent
  2. Length check — for sequences, verify element count
  3. Element/attribute access — extract values for sub-patterns
  4. Guard evaluation — if a guard exists, evaluate it as a final filter

The compiler optimises common cases. A match with only literal patterns compiles similarly to a dictionary lookup in some implementations, though CPython 3.10–3.12 uses sequential checking.

The __match_args__ protocol

Class patterns like Point(1, 2) use positional matching, which requires the class to define __match_args__:

class Point:
    __match_args__ = ("x", "y")
    
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

match point:
    case Point(0, 0):
        print("Origin")
    case Point(x, 0):
        print(f"On x-axis at {x}")
    case Point(0, y):
        print(f"On y-axis at {y}")
    case Point(x, y):
        print(f"At ({x}, {y})")

Dataclasses automatically generate __match_args__ from their field definitions, making them natural companions for pattern matching:

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

Shape = Circle | Rectangle

def area(shape: Shape) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r ** 2
        case Rectangle(width=w, height=h):
            return w * h

Nested destructuring

Patterns compose. You can nest sequence patterns inside mapping patterns inside class patterns:

match event:
    case {"type": "batch", "items": [first, second, *rest]}:
        print(f"Batch with {2 + len(rest)} items, first: {first}")
    case {"type": "error", "details": {"code": code, "message": msg}}:
        print(f"Error {code}: {msg}")

This eliminates chains of key lookups and index accesses that would otherwise require defensive get() calls and length checks.

Building a command dispatcher

A practical pattern for CLI tools or chatbots:

def dispatch(tokens: list[str]) -> str:
    match tokens:
        case ["help"]:
            return show_help()
        case ["get", resource]:
            return fetch_resource(resource)
        case ["get", resource, "--format", fmt]:
            return fetch_resource(resource, format=fmt)
        case ["set", resource, value]:
            return update_resource(resource, value)
        case ["delete", resource] if confirm_deletion(resource):
            return delete_resource(resource)
        case ["delete", _]:
            return "Deletion cancelled"
        case [command, *_]:
            return f"Unknown command: {command}"
        case []:
            return "No command provided"

Each case handles a different command shape. Guards add runtime conditions without nesting if statements. The *_ captures and discards extra arguments.

AST walking with pattern matching

Pattern matching transforms AST processing from verbose visitor patterns into concise, readable dispatch:

import ast

def count_functions(node: ast.AST) -> int:
    match node:
        case ast.Module(body=statements):
            return sum(count_functions(s) for s in statements)
        case ast.FunctionDef() | ast.AsyncFunctionDef():
            return 1 + sum(
                count_functions(child) for child in ast.iter_child_nodes(node)
            )
        case ast.ClassDef(body=statements):
            return sum(count_functions(s) for s in statements)
        case _:
            return sum(
                count_functions(child) for child in ast.iter_child_nodes(node)
            )

Compare this to the traditional ast.NodeVisitor approach — the match version makes the structural expectations explicit in each case.

Protocol message parsing

Pattern matching excels when handling messages from external systems with varying schemas:

def handle_webhook(payload: dict) -> None:
    match payload:
        case {
            "event": "payment.completed",
            "data": {"amount": int(amount), "currency": str(currency)},
        }:
            record_payment(amount, currency)
        
        case {
            "event": "payment.failed",
            "data": {"error_code": code},
        } if code in RETRYABLE_CODES:
            schedule_retry(payload)
        
        case {
            "event": "payment.failed",
            "data": {"error_code": code},
        }:
            alert_team(f"Non-retryable payment failure: {code}")
        
        case {"event": event_type}:
            log_unhandled_event(event_type)

The nested type checks (int(amount), str(currency)) validate types and extract values simultaneously — something that would require multiple isinstance calls and dictionary lookups without pattern matching.

Performance characteristics

Pattern matching performance depends on the pattern type:

Pattern typeApproximate costNotes
LiteralO(n) cases checkedSequential comparison in CPython
Capture/wildcardO(1)Always matches
SequenceO(k) per casek = number of elements in pattern
MappingO(k) per casek = number of keys checked
ClassO(1) isinstance + O(k) attrsk = number of attributes checked
ORSum of component costsEach alternative checked

For hot paths with many literal cases (>20), a dictionary dispatch may be faster. For structural patterns, the readability benefit usually outweighs minor performance differences.

# Dictionary dispatch for pure literal matching
HANDLERS = {
    "click": handle_click,
    "keypress": handle_keypress,
    "scroll": handle_scroll,
}

handler = HANDLERS.get(event_type, handle_unknown)
handler(event)

Edge cases and gotchas

Name binding vs constant matching

STATUS_OK = 200

match response.status:
    case STATUS_OK:  # BUG: This captures into STATUS_OK, doesn't compare!
        pass

To match constants, use dotted names or enums:

from enum import Enum

class Status(Enum):
    OK = 200
    NOT_FOUND = 404

match response.status:
    case Status.OK:  # Correct: dotted name is a value pattern
        pass

Sequence matching gotcha with strings

Strings are sequences in Python, so case [first, *rest]: matches a string character by character. If you want to match strings as atoms, put the string literal pattern before any sequence pattern:

match value:
    case str():        # Match any string as a whole
        handle_string(value)
    case [x, y]:       # Match a two-element list
        handle_pair(x, y)

Guards can access captured variables

match point:
    case (x, y) if x == y:
        print(f"On diagonal at {x}")

The variables x and y are bound by the pattern and available in the guard. This enables powerful filtering without separate if statements.

Real-world adoption

The Python standard library itself uses pattern matching in ast.py and tomllib. Major frameworks are adopting it:

  • FastAPI uses it internally for request routing decisions
  • Textual (terminal UI framework) uses it for event handling
  • Polars uses match statements in its Python expression engine

The pattern is also common in data engineering pipelines where incoming records have varying schemas — Airflow DAGs, Kafka consumers, and ETL processors benefit from explicit structural dispatch.

Comparison with alternatives

ApproachReadabilityType narrowingDestructuringPerformance
if/elif chainLow for >3 casesManual isinstanceManual indexingGood
Dictionary dispatchMediumNoneNoneBest for literals
match/caseHighAutomaticBuilt-inGood
Visitor patternMediumVia method dispatchManualGood

The one thing to remember: Structural pattern matching combines type checking, destructuring, and conditional logic into a single readable construct — use it whenever your code branches on the shape of data rather than simple equality.

pythonfundamentalspython310

See Also

  • Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
  • Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
  • Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
  • Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
  • Python Exception Groups Python's ExceptionGroup is like getting one report card that lists every mistake at once instead of stopping at the first one.