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.
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.
Related articles
- Rust Rust Pattern Matching Tutorial
Use Rust's match expression, if-let, while-let, and pattern syntax to destructure data safely with exhaustive compile-time checking.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Environments: venv, pyenv, and Poetry Guide
Master Python environment management with venv for virtual environments, pyenv for version switching, and Poetry for dependency management.
- Python Pydantic v2: Data Validation and Settings Management
Learn Pydantic v2 for data validation, serialization, and settings management in Python. Covers models, validators, computed fields, and BaseSettings.