Python Match Statements: The Pattern Matching Feature That Changes How You Think About Control Flow

If you learned Python before version 3.10, you probably have a mental model of conditional logic that looks like a chain of if, elif, and else blocks. It gets the job done. But when conditions grow complex — when you need to check not just what something is but what it looks like, what it contains, or what shape it takes — that chain can turn into a tangled mess fast.

That is exactly the problem Python's match statement was designed to solve. Introduced in Python 3.10 via PEP 634, structural pattern matching gives Python a mechanism that goes far beyond a simple value-comparison switch. It lets you match against the structure of data — and that distinction is what makes it genuinely powerful.

What a Match Statement Actually Does

At its core, a match statement takes a subject — any expression — and tests it against a series of patterns defined in case blocks. The first pattern that matches gets executed. If nothing matches, nothing happens (no error, no fallback unless you define one).

Here is the basic anatomy:

match subject:
    case pattern_1:
        # runs if pattern_1 matches
    case pattern_2:
        # runs if pattern_2 matches
    case _:
        # wildcard — runs if nothing else matches

That underscore in the final case is the catch-all wildcard. It is Python's equivalent of a default case. It always matches and never binds any variable.

Note

This looks like a switch statement from C, Java, or JavaScript on the surface. But calling it a switch statement would be underselling it significantly. A switch checks equality. A match statement checks structure. Those are very different operations.

Literal Pattern Matching: The Simplest Form

The simplest use of match is exactly what it looks like — comparing a value against literal options. This is the closest it gets to a traditional switch:

def http_status_message(status):
    match status:
        case 200:
            return "OK"
        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 "Unknown Status"

This is immediately cleaner than a chain of elif comparisons. It is easier to scan, easier to extend, and the intent is obvious at a glance. You can also match against string literals, which makes it useful for command parsing:

def handle_command(command):
    match command.lower().strip():
        case "start":
            print("Starting the process...")
        case "stop":
            print("Stopping the process...")
        case "restart":
            print("Restarting...")
        case "status":
            print("Checking status...")
        case _:
            print(f"Unknown command: {command}")

OR Patterns: Combining Cases

What if multiple values should produce the same result? Python's match syntax handles this with the | operator inside a case:

def classify_response(code):
    match code:
        case 200 | 201 | 204:
            return "Success"
        case 301 | 302 | 307 | 308:
            return "Redirect"
        case 400 | 401 | 403 | 404 | 422:
            return "Client Error"
        case 500 | 502 | 503 | 504:
            return "Server Error"
        case _:
            return "Unrecognized"

This is considerably more readable than repeating or conditions across elif statements, especially when the groupings grow.

Sequence Patterns: Matching Against Structure

Here is where match starts to diverge meaningfully from anything a switch statement can do. You can match against the shape of a sequence — its length, the types of its elements, or specific values at specific positions.

def describe_point(point):
    match point:
        case (0, 0):
            return "Origin"
        case (x, 0):
            return f"On the x-axis at {x}"
        case (0, y):
            return f"On the y-axis at {y}"
        case (x, y):
            return f"Point at ({x}, {y})"

Notice what is happening in case (x, 0): Python is simultaneously checking that the second element is zero and binding the first element to the variable x. This is destructuring and conditional checking in a single expression.

This pattern extends naturally to lists:

def process_args(args):
    match args:
        case []:
            print("No arguments provided")
        case [single]:
            print(f"One argument: {single}")
        case [first, second]:
            print(f"Two arguments: {first} and {second}")
        case [first, *rest]:
            print(f"First: {first}, remaining: {rest}")
Pro Tip

The *rest syntax works just like starred assignment in regular Python — it captures everything that remains after the matched prefix. You can use it anywhere in the sequence pattern, not just at the end.

Mapping Patterns: Matching Dictionaries

Dictionaries are everywhere in Python — API responses, configuration files, event payloads. The mapping pattern lets you match against specific keys and extract their values simultaneously:

def handle_event(event):
    match event:
        case {"type": "click", "button": button}:
            print(f"Mouse click with button: {button}")
        case {"type": "keypress", "key": key}:
            print(f"Key pressed: {key}")
        case {"type": "scroll", "delta": delta}:
            print(f"Scroll event, delta: {delta}")
        case {"type": unknown_type}:
            print(f"Unknown event type: {unknown_type}")
        case _:
            print("Malformed event")

A critical detail: mapping patterns do partial matching by default. If the dictionary has extra keys beyond what the pattern specifies, it still matches. If you need to ensure no extra keys are present, you can use **rest to capture them and check for emptiness explicitly.

This is enormously useful for processing JSON API responses:

import json

def parse_api_response(raw_json):
    data = json.loads(raw_json)
    match data:
        case {"status": "success", "data": {"user": {"id": uid, "name": name}}}:
            print(f"Logged in: {name} (ID: {uid})")
        case {"status": "error", "message": msg}:
            print(f"API error: {msg}")
        case {"status": "pending"}:
            print("Request is still processing")
        case _:
            print("Unexpected response format")

Without pattern matching, this kind of nested dictionary inspection requires multiple levels of .get() calls and conditional checks. The match version tells the whole story in the pattern itself.

Class Patterns: Matching Against Objects

This is arguably the most powerful form. If you are using dataclasses, named tuples, or regular classes, you can match against instances and extract attributes in the same step:

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Triangle:
    base: float
    height: float

def compute_area(shape):
    match shape:
        case Circle(radius=r):
            return 3.14159 * r ** 2
        case Rectangle(width=w, height=h):
            return w * h
        case Triangle(base=b, height=h):
            return 0.5 * b * h
        case _:
            raise ValueError(f"Unknown shape: {shape}")

This replaces a chain of isinstance() checks with something that reads almost like documentation. The intent is front and center, and the attribute extraction happens automatically.

Guards: Adding Conditions to Patterns

Sometimes a pattern is necessary but not sufficient. You want to match a structure and verify a condition. That is what guards are for — an if clause that follows the pattern:

def classify_number(n):
    match n:
        case int(x) if x < 0:
            return "Negative integer"
        case int(x) if x == 0:
            return "Zero"
        case int(x) if x % 2 == 0:
            return "Positive even integer"
        case int(x):
            return "Positive odd integer"
        case float(x) if x != x:  # NaN check
            return "Not a number"
        case float():
            return "Float"
        case _:
            return "Not a number type"

Guards give you the expressiveness of arbitrary boolean logic while keeping the structural matching intact.

Real-World Application: A Simple Command-Line Tool

Pulling several patterns together, here is a practical example — a mini task manager that processes string commands:

def process_task_command(command_parts):
    match command_parts:
        case ["add", task_name]:
            print(f"Adding task: {task_name}")
        case ["add", task_name, "--priority", priority] if priority in ("low", "medium", "high"):
            print(f"Adding task '{task_name}' with {priority} priority")
        case ["delete", task_id] if task_id.isdigit():
            print(f"Deleting task #{task_id}")
        case ["list"]:
            print("Listing all tasks")
        case ["list", "--status", status]:
            print(f"Listing tasks with status: {status}")
        case ["complete", task_id] if task_id.isdigit():
            print(f"Marking task #{task_id} as complete")
        case ["help"]:
            print("Available commands: add, delete, list, complete, help")
        case _:
            print("Unrecognized command. Type 'help' for options.")

# Usage
process_task_command(["add", "Write documentation"])
process_task_command(["add", "Fix bug", "--priority", "high"])
process_task_command(["list", "--status", "pending"])
process_task_command(["delete", "7"])

The command_parts variable would typically come from splitting user input: input().strip().split(). What you get is clean, readable command parsing without a deeply nested tangle of conditionals.

Real-World Application: Parsing Configuration

Configuration validation is another place match shines. Suppose you are reading YAML or JSON configuration and need to validate structure before using values:

def validate_db_config(config):
    match config:
        case {"engine": "sqlite", "path": path}:
            return f"SQLite database at {path}"
        case {"engine": "postgresql", "host": host, "port": int(port), "name": name}:
            return f"PostgreSQL at {host}:{port}, database '{name}'"
        case {"engine": "mysql", "host": host, "name": name}:
            return f"MySQL at {host}, database '{name}'"
        case {"engine": engine}:
            return f"Unsupported database engine: {engine}"
        case _:
            return "Invalid configuration structure"

The int(port) in the postgresql case is a class pattern — it matches only if port is actually an integer, not a string representation of one. You get type checking built into the pattern.

Real-World Application: State Machine Logic

State machines are a natural fit for pattern matching. Consider a simple traffic light controller:

def next_state(current_state, event):
    match (current_state, event):
        case ("red", "timer_expired"):
            return "green"
        case ("green", "timer_expired"):
            return "yellow"
        case ("yellow", "timer_expired"):
            return "red"
        case ("green", "emergency"):
            return "red"
        case (state, "power_outage"):
            return "off"
        case ("off", "power_restored"):
            return "red"
        case (state, event):
            print(f"No transition defined for state '{state}' + event '{event}'")
            return state

state = "red"
for event in ["timer_expired", "timer_expired", "emergency", "timer_expired"]:
    state = next_state(state, event)
    print(f"After '{event}': {state}")

Matching on a tuple of (state, event) pairs gives you a complete state transition table in an easily auditable format.

What Match Does Not Do

A few important clarifications to avoid confusion:

Match does not fall through. Unlike C or JavaScript switch statements, once a Python case matches and executes, the match block is done. There is no need for break and no accidental fall-through behavior.

Match evaluates patterns, not arbitrary expressions. You cannot use a variable lookup directly as a pattern (it would be interpreted as a capture variable). For value comparisons against variables, use guards or dotted names (like enum members via Color.RED).

Version Requirement

Match requires Python 3.10 or later. If your environment is on an older version, the syntax will cause a SyntaxError. Check with python --version before relying on it.

Performance Considerations

For simple literal matching, match performs comparably to if/elif chains. For structural patterns involving sequence or mapping checks, there is some additional overhead — but in practice this is negligible for many applications. The primary benefit of match is readability and correctness, not raw speed. If performance is a critical constraint at a hotspot, profile before optimizing.

Key Takeaways

  1. Match is structural, not just comparative: It does not simply check whether two values are equal — it checks whether data conforms to a particular shape, which is a fundamentally more expressive operation.
  2. Patterns can capture and check simultaneously: Sequence, mapping, and class patterns extract values from data at the same time they verify structure, eliminating layers of boilerplate.
  3. Guards extend patterns with arbitrary logic: When a structural check alone is not enough, an if guard lets you add any boolean condition without abandoning the clarity of the match block.
  4. No fall-through, no break: Python's match executes exactly one matching case and stops — behavior that is safer and more predictable than switch statements in languages like C or JavaScript.
  5. The best places to start are your existing elif chains and isinstance() checks: These are the spots in real code where a match statement will make the biggest difference in readability and maintainability.

Python's match statement represents a shift in how the language thinks about control flow. Rather than asking "what is this value?", it lets you ask "what does this data look like?" — and then act on the structure directly. For developers working with APIs, parsing user input, handling events, processing configuration files, or implementing state machines, this is a meaningful tool. Python 3.10 dropped in October 2021. If you are not using match yet, now is a good time to start.

back to articles