Skip to content
Codeloom
Python

Python Match Statement: Structural Pattern Matching

Master Python's match statement with structural pattern matching. Learn literal, sequence, mapping, class, guard, and OR patterns with practical examples.

·8 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Use match/case for cleaner multi-branch logic
  • Destructure sequences, mappings, and objects in patterns
  • Apply guard clauses and OR patterns for complex conditions
  • Build real-world parsers and command handlers with match

Prerequisites

  • Python basics: functions, classes, data structures
  • Python 3.10 or later

Python 3.10 introduced structural pattern matching with the match statement. It looks like a switch/case from other languages, but it goes far beyond simple value comparison. Pattern matching can destructure sequences, inspect dictionary shapes, match class attributes, and combine patterns with logical operators. This guide covers every pattern type with practical code.

Basic Syntax

The match statement evaluates a subject and compares it against patterns in case clauses:

def http_status(code: int) -> str:
    match code:
        case 200:
            return "OK"
        case 301:
            return "Moved Permanently"
        case 404:
            return "Not Found"
        case 500:
            return "Internal Server Error"
        case _:
            return f"Unknown status: {code}"

print(http_status(404))  # Not Found
print(http_status(418))  # Unknown status: 418

The underscore _ is the wildcard pattern. It matches anything and serves as the default case. Unlike other languages, Python does not fall through to the next case.

Literal Patterns

Match against strings, numbers, booleans, and None:

def classify_input(value: str | int | None) -> str:
    match value:
        case None:
            return "no value"
        case True:
            return "boolean true"
        case False:
            return "boolean false"
        case 0:
            return "zero"
        case "":
            return "empty string"
        case _:
            return f"other: {value}"

Important: True matches before 1 and False matches before 0 because Python checks patterns top to bottom, and True == 1 and False == 0 in Python. Place boolean cases before integer cases.

OR Patterns with |

Combine multiple patterns that should trigger the same action:

def classify_day(day: str) -> str:
    match day.lower():
        case "monday" | "tuesday" | "wednesday" | "thursday" | "friday":
            return "weekday"
        case "saturday" | "sunday":
            return "weekend"
        case _:
            return "invalid day"

print(classify_day("Saturday"))  # weekend

Capture Patterns

A bare name in a pattern captures the matched value into a variable:

def describe(value: object) -> str:
    match value:
        case 0:
            return "zero"
        case n:  # captures any value into 'n'
            return f"got: {n}"

print(describe(42))  # got: 42

Be careful: case n matches everything, just like case _, but it also binds the value to n. This means named constants do not work as you might expect.

Using Constants with Dotted Names

To match against a named constant, use a dotted name:

class Status:
    OK = 200
    NOT_FOUND = 404
    ERROR = 500

def handle_status(code: int) -> str:
    match code:
        case Status.OK:
            return "success"
        case Status.NOT_FOUND:
            return "not found"
        case Status.ERROR:
            return "server error"
        case _:
            return "unknown"

Only dotted names (like Status.OK) are treated as constants. A bare name like OK would be a capture pattern.

Sequence Patterns

Match and destructure lists, tuples, and other sequences:

def process_command(command: list[str]) -> str:
    match command:
        case ["quit"]:
            return "Goodbye!"
        case ["hello", name]:
            return f"Hello, {name}!"
        case ["add", *items]:
            return f"Adding {len(items)} items: {', '.join(items)}"
        case ["move", x, y]:
            return f"Moving to ({x}, {y})"
        case []:
            return "Empty command"
        case _:
            return f"Unknown command: {command}"

print(process_command(["hello", "Alice"]))
# Hello, Alice!

print(process_command(["add", "milk", "eggs", "bread"]))
# Adding 3 items: milk, eggs, bread

The *items syntax captures remaining elements, similar to *args in function definitions.

Nested Sequence Patterns

def analyze_matrix(matrix: list[list[int]]) -> str:
    match matrix:
        case [[a]]:
            return f"1x1 matrix: {a}"
        case [[a, b], [c, d]]:
            return f"2x2 matrix, trace={a + d}"
        case [[_, *_], *_]:
            return f"Matrix with {len(matrix)} rows"
        case []:
            return "Empty matrix"
        case _:
            return "Not a matrix"

print(analyze_matrix([[1, 2], [3, 4]]))
# 2x2 matrix, trace=5

Mapping Patterns

Match dictionaries by their keys:

def handle_event(event: dict) -> str:
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"Click at ({x}, {y})"
        case {"type": "keypress", "key": key}:
            return f"Key pressed: {key}"
        case {"type": "scroll", "direction": "up" | "down" as direction}:
            return f"Scrolled {direction}"
        case {"type": event_type}:
            return f"Unknown event: {event_type}"
        case _:
            return "Invalid event"

print(handle_event({"type": "click", "x": 100, "y": 200, "button": "left"}))
# Click at (100, 200)

Mapping patterns match if the specified keys are present. Extra keys (like "button" above) are ignored. Use **rest to capture extra keys:

def parse_config(config: dict) -> str:
    match config:
        case {"host": host, "port": port, **rest}:
            extras = f" (+{len(rest)} more settings)" if rest else ""
            return f"Server at {host}:{port}{extras}"
        case _:
            return "Invalid config"

print(parse_config({"host": "localhost", "port": 8080, "debug": True, "workers": 4}))
# Server at localhost:8080 (+2 more settings)

Class Patterns

Match against class instances and destructure their attributes:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

@dataclass
class Circle:
    center: Point
    radius: float

@dataclass
class Rectangle:
    origin: Point
    width: float
    height: float

type Shape = Circle | Rectangle

def describe_shape(shape: Shape) -> str:
    match shape:
        case Circle(center=Point(x=0, y=0), radius=r):
            return f"Circle at origin with radius {r}"
        case Circle(center=Point(x=x, y=y), radius=r):
            return f"Circle at ({x}, {y}) with radius {r}"
        case Rectangle(origin=Point(x=x, y=y), width=w, height=h) if w == h:
            return f"Square at ({x}, {y}) with side {w}"
        case Rectangle(origin=Point(x=x, y=y), width=w, height=h):
            return f"Rectangle at ({x}, {y}), {w}x{h}"

print(describe_shape(Circle(Point(0, 0), 5.0)))
# Circle at origin with radius 5.0

print(describe_shape(Rectangle(Point(1, 2), 10, 10)))
# Square at (1, 2) with side 10

Positional Patterns with match_args

Dataclasses automatically set __match_args__, allowing positional matching:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

def classify_point(p: Point) -> str:
    match p:
        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) if x == y:
            return f"on diagonal at {x}"
        case Point(x, y):
            return f"at ({x}, {y})"

For custom classes, define __match_args__ manually:

class Color:
    __match_args__ = ("r", "g", "b")

    def __init__(self, r: int, g: int, b: int):
        self.r = r
        self.g = g
        self.b = b

Guard Clauses

Add if conditions to patterns for extra filtering:

def categorize_score(score: int) -> str:
    match score:
        case n if n < 0 or n > 100:
            return "invalid"
        case n if n >= 90:
            return "A"
        case n if n >= 80:
            return "B"
        case n if n >= 70:
            return "C"
        case n if n >= 60:
            return "D"
        case _:
            return "F"

Guards are checked after the pattern matches. If the guard is False, matching continues with the next case.

AS Patterns

Bind a sub-pattern to a name with as:

def process_data(data: list | dict | str) -> str:
    match data:
        case [1, 2, *rest] as full_list:
            return f"Starts with [1, 2], total {len(full_list)} items"
        case {"status": ("active" | "pending") as status}:
            return f"Valid status: {status}"
        case str() as text if len(text) > 100:
            return f"Long text: {len(text)} chars"
        case _:
            return "unhandled"

Real-World Example: JSON API Router

from dataclasses import dataclass
from typing import Any

@dataclass
class Response:
    status: int
    body: dict[str, Any]

def route_request(method: str, path: str, body: dict | None = None) -> Response:
    match (method, path.split("/")[1:]):
        case ("GET", ["users"]):
            return Response(200, {"users": ["Alice", "Bob"]})

        case ("GET", ["users", user_id]):
            return Response(200, {"user": {"id": user_id}})

        case ("POST", ["users"]) if body:
            match body:
                case {"name": str(name), "email": str(email)}:
                    return Response(201, {"created": {"name": name, "email": email}})
                case _:
                    return Response(400, {"error": "name and email required"})

        case ("DELETE", ["users", user_id]):
            return Response(204, {"deleted": user_id})

        case ("GET" | "POST" | "PUT" | "DELETE" as method, path_parts):
            return Response(404, {"error": f"{method} /{'/'.join(path_parts)} not found"})

        case _:
            return Response(405, {"error": "Method not allowed"})

print(route_request("GET", "/users"))
# Response(status=200, body={'users': ['Alice', 'Bob']})

print(route_request("POST", "/users", {"name": "Charlie", "email": "c@test.com"}))
# Response(status=201, body={'created': {'name': 'Charlie', 'email': 'c@test.com'}})

When to Use match vs if/elif

Use match when you are:

  • Destructuring data (sequences, dicts, objects)
  • Handling multiple distinct cases for a value
  • Building command parsers or protocol handlers
  • Working with algebraic data types (union of dataclasses)

Use if/elif when you are:

  • Checking simple boolean conditions
  • Comparing against computed values
  • Writing conditions that do not benefit from destructuring

match is not just syntactic sugar for if/elif. Its power comes from destructuring. If you are not destructuring, if/elif is often clearer.

Wrapping Up

Python’s match statement brings structural pattern matching that goes far beyond switch/case. Literal patterns handle simple value matching. Sequence and mapping patterns destructure lists and dictionaries in place. Class patterns pull apart object attributes. Guard clauses add conditional logic to any pattern. OR patterns combine alternatives. Used together, these features let you write expressive, readable code for parsing, routing, command handling, and data transformation. Start with simple literal matching and add destructuring as your data structures demand it.