Python - Match/Case Statement (Python 3.10+)

Python 3.10 introduced structural pattern matching through PEP 634, and it's one of the most significant additions to the language in years. But here's where most tutorials get it wrong: match/case...

Key Insights

  • Match/case is structural pattern matching, not a switch statement—it destructures data and binds variables, making it ideal for parsing complex nested structures
  • The wildcard _ pattern is essential for exhaustive matching; forgetting it leads to silent failures when no pattern matches
  • Use match/case for data structure parsing and state machines, but stick with if/elif for simple value comparisons—pattern matching shines when you need to destructure

Introduction to Structural Pattern Matching

Python 3.10 introduced structural pattern matching through PEP 634, and it’s one of the most significant additions to the language in years. But here’s where most tutorials get it wrong: match/case isn’t Python’s version of a switch statement. It’s fundamentally different.

While switch statements in C, Java, or JavaScript compare a value against constants, Python’s match/case performs structural pattern matching. It can destructure data, bind variables, and match against the shape of objects—not just their values.

Consider this comparison:

# The old way: if/elif chains
def handle_command_old(command):
    if command == "quit":
        return "Exiting..."
    elif command == "help":
        return "Available commands: quit, help, status"
    elif command == "status":
        return "System operational"
    else:
        return f"Unknown command: {command}"

# The new way: match/case
def handle_command_new(command):
    match command:
        case "quit":
            return "Exiting..."
        case "help":
            return "Available commands: quit, help, status"
        case "status":
            return "System operational"
        case _:
            return f"Unknown command: {command}"

For simple string matching, these are equivalent. But match/case becomes powerful when you need to inspect structure, which we’ll explore shortly.

Basic Syntax and Literal Patterns

The core syntax is straightforward: match takes a subject, and each case specifies a pattern. The wildcard _ acts as a catch-all that matches anything.

from enum import Enum

class HttpStatus(Enum):
    OK = 200
    CREATED = 201
    BAD_REQUEST = 400
    NOT_FOUND = 404
    INTERNAL_ERROR = 500

def handle_response(status: HttpStatus) -> str:
    match status:
        case HttpStatus.OK:
            return "Request successful"
        case HttpStatus.CREATED:
            return "Resource created"
        case HttpStatus.BAD_REQUEST:
            return "Client error: check your request"
        case HttpStatus.NOT_FOUND:
            return "Resource not found"
        case HttpStatus.INTERNAL_ERROR:
            return "Server error: try again later"
        case _:
            return "Unexpected status code"

You can also match multiple patterns using the OR operator:

def categorize_status(code: int) -> str:
    match code:
        case 200 | 201 | 204:
            return "success"
        case 400 | 401 | 403 | 404:
            return "client_error"
        case 500 | 502 | 503:
            return "server_error"
        case _:
            return "unknown"

Structural Patterns and Destructuring

This is where match/case truly differentiates itself. You can match against the structure of sequences and extract values simultaneously.

def process_coordinate(point):
    match point:
        case (0, 0):
            return "Origin"
        case (0, y):
            return f"On Y-axis at y={y}"
        case (x, 0):
            return f"On X-axis at x={x}"
        case (x, y):
            return f"Point at ({x}, {y})"
        case (x, y, z):
            return f"3D point at ({x}, {y}, {z})"
        case _:
            return "Not a valid coordinate"

# Usage
print(process_coordinate((0, 0)))      # Origin
print(process_coordinate((5, 0)))      # On X-axis at x=5
print(process_coordinate((3, 4)))      # Point at (3, 4)
print(process_coordinate((1, 2, 3)))   # 3D point at (1, 2, 3)

Dictionary matching is equally powerful for processing JSON-like structures:

def process_event(event: dict) -> str:
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"Click at ({x}, {y})"
        case {"type": "keypress", "key": key, "modifiers": [*mods]}:
            mod_str = "+".join(mods) if mods else "none"
            return f"Key '{key}' pressed with modifiers: {mod_str}"
        case {"type": "scroll", "direction": "up" | "down" as direction}:
            return f"Scroll {direction}"
        case {"type": event_type}:
            return f"Unknown event type: {event_type}"
        case _:
            return "Invalid event format"

# Usage
print(process_event({"type": "click", "x": 100, "y": 200}))
# Click at (100, 200)

print(process_event({"type": "keypress", "key": "a", "modifiers": ["ctrl", "shift"]}))
# Key 'a' pressed with modifiers: ctrl+shift

Note that dictionary patterns match subsets—extra keys in the subject don’t prevent a match.

Class Patterns and Object Matching

Match/case can inspect object attributes, making it invaluable for processing ASTs, handling polymorphic data, or working with dataclasses.

from dataclasses import dataclass

@dataclass
class Add:
    left: 'Expr'
    right: 'Expr'

@dataclass
class Mul:
    left: 'Expr'
    right: 'Expr'

@dataclass
class Num:
    value: int

Expr = Add | Mul | Num

def evaluate(expr: Expr) -> int:
    match expr:
        case Num(value):
            return value
        case Add(left, right):
            return evaluate(left) + evaluate(right)
        case Mul(left, right):
            return evaluate(left) * evaluate(right)
        case _:
            raise ValueError(f"Unknown expression: {expr}")

# Build expression: (2 + 3) * 4
expr = Mul(Add(Num(2), Num(3)), Num(4))
print(evaluate(expr))  # 20

Dataclasses automatically define __match_args__, enabling positional matching. For regular classes, you can define it explicitly:

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

def classify_point(p):
    match p:
        case Point(0, 0):
            return "origin"
        case Point(x, y) if x == y:
            return "diagonal"
        case Point(x, y):
            return f"point({x}, {y})"

Guards and Combined Patterns

Guards add conditional logic to patterns using if clauses. This lets you match structure first, then apply additional constraints.

def categorize_number(value):
    match value:
        case int(n) if n < 0:
            return "negative integer"
        case int(n) if n == 0:
            return "zero"
        case int(n) if 1 <= n <= 100:
            return "small positive integer"
        case int(n):
            return "large positive integer"
        case float(f) if f.is_integer():
            return "float with integer value"
        case float():
            return "float"
        case _:
            return "not a number"

The as pattern lets you bind a matched value to a name while also matching its structure:

def process_response(response):
    match response:
        case {"status": "error", "code": code, "message": msg} as error_response:
            log_error(error_response)  # Log the full response
            return f"Error {code}: {msg}"
        case {"status": "success", "data": {"items": [first, *rest]}}:
            return f"Found {len(rest) + 1} items, first: {first}"
        case {"status": "success", "data": data}:
            return f"Success with data: {data}"
        case _:
            return "Invalid response format"

Real-World Use Cases

Here’s a practical CLI command dispatcher that demonstrates match/case’s strengths:

from dataclasses import dataclass
from typing import Optional

@dataclass
class Command:
    name: str
    args: list[str]
    flags: dict[str, str]

def parse_command(raw: str) -> Command:
    parts = raw.split()
    name = parts[0] if parts else ""
    args = []
    flags = {}
    
    i = 1
    while i < len(parts):
        if parts[i].startswith("--"):
            key = parts[i][2:]
            value = parts[i + 1] if i + 1 < len(parts) else ""
            flags[key] = value
            i += 2
        else:
            args.append(parts[i])
            i += 1
    
    return Command(name, args, flags)

def execute(cmd: Command) -> str:
    match cmd:
        case Command("help", [], {}):
            return "Usage: <command> [args] [--flags]"
        case Command("help", [topic], {}):
            return f"Help for: {topic}"
        case Command("copy", [src, dst], {"force": "true"}):
            return f"Force copying {src} to {dst}"
        case Command("copy", [src, dst], {}):
            return f"Copying {src} to {dst}"
        case Command("list", [], {"format": fmt}):
            return f"Listing in {fmt} format"
        case Command("list", [], {}):
            return "Listing in default format"
        case Command(name, args, _):
            return f"Unknown command: {name} with args {args}"

# Usage
print(execute(parse_command("help")))
print(execute(parse_command("copy file.txt backup.txt --force true")))
print(execute(parse_command("list --format json")))

For API response handling:

def handle_api_response(response: dict) -> str:
    match response:
        case {"error": {"code": 401, "message": msg}}:
            refresh_auth_token()
            return f"Auth failed: {msg}"
        case {"error": {"code": code, "message": msg}} if 400 <= code < 500:
            return f"Client error ({code}): {msg}"
        case {"error": {"code": code}} if code >= 500:
            return f"Server error: retry later"
        case {"data": {"users": [{"id": id, "name": name}, *_]}}:
            return f"First user: {name} (id={id})"
        case {"data": {"users": []}}:
            return "No users found"
        case {"data": data}:
            return f"Received: {data}"
        case _:
            return "Unexpected response format"

Best Practices and Pitfalls

When to use match/case:

  • Parsing nested data structures (JSON, ASTs, protocol messages)
  • State machines and event dispatching
  • Processing polymorphic objects with different shapes

When to stick with if/elif:

  • Simple value comparisons
  • When you need complex boolean logic that doesn’t fit guards
  • Python < 3.10 compatibility requirements

Common mistakes to avoid:

  1. Variable capture confusion: Bare names capture values; they don’t match constants.
STATUS_OK = 200

def bad_match(code):
    match code:
        case STATUS_OK:  # This CAPTURES code into STATUS_OK!
            return "ok"

def good_match(code):
    match code:
        case status if status == STATUS_OK:  # Use a guard
            return "ok"
  1. Forgetting the wildcard: Without _, unmatched cases silently do nothing.

  2. Over-engineering: Don’t use match/case for simple conditionals just because it’s new.

Performance note: Match/case has comparable performance to if/elif chains for simple cases. The real benefit is readability and maintainability when dealing with complex structures, not speed.

Match/case is a powerful tool that fundamentally changes how you can write Python code for certain problem domains. Master structural patterns and destructuring, and you’ll find yourself writing cleaner, more declarative code for parsing and dispatching tasks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.