Skip to content

Python Switch Case: match-case Statement Explained

Updated on

You are writing a command processor in Python. The function takes a string like "quit", "save", "load", or "help" and needs to run different logic for each command. In JavaScript or C you would use a switch statement. In Python, you write a chain of if/elif/else blocks — five lines for two commands, fifteen for six, thirty for twelve. The function grows into a wall of comparisons that is tedious to read, easy to misorder, and painful to extend.

📚

This was a real limitation of Python for over 30 years. Every other major language had a switch or case construct, but Python relied entirely on if/elif chains and dictionary dispatch. Guido van Rossum rejected switch-case proposals multiple times, arguing that if/elif was sufficient.

That changed in Python 3.10 with PEP 634 (opens in a new tab), which introduced the match-case statement — Python's answer to switch-case, but far more powerful. Instead of just matching values, it can destructure objects, match patterns, bind variables, and apply conditions. It is structural pattern matching, not just a switch replacement.

Quick Syntax: match-case in 30 Seconds

def handle_command(command):
    match command:
        case "quit":
            print("Exiting program")
        case "save":
            print("Saving file")
        case "help":
            print("Showing help")
        case _:
            print(f"Unknown command: {command}")
 
handle_command("save")   # Saving file
handle_command("hello")  # Unknown command: hello

The match keyword evaluates the subject expression once. Each case defines a pattern. Python tests patterns top to bottom and executes the first match. The wildcard _ is the default case — it matches anything.

match-case vs switch-case: Key Differences

If you are coming from C, Java, JavaScript, or Go, here are the critical differences:

FeaturePython match-caseC/Java switch-case
Fall-throughNo (each case is independent)Yes (requires break)
Pattern typesLiterals, sequences, mappings, classes, OR, guardsMostly literals only
DestructuringBuilt-in (bind variables from matched structure)Not supported
Variable bindingCaptures values from matched patternNot supported
Default casecase _:default:
Expression/statementStatement (no return value)Statement
Minimum Python3.10+N/A

The most important difference: no fall-through. Each case block is isolated. You never need a break statement, and you never accidentally execute the next case.

Pattern Types: What match-case Can Do

Python's match-case supports seven pattern types. This is what makes it fundamentally more powerful than a traditional switch statement.

1. Literal Patterns

Match exact values — strings, numbers, booleans, None:

def classify_status(code):
    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(classify_status(404))  # Not Found

2. OR Patterns (Multiple Values)

Use the | operator to match several values in one case:

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

This replaces the common if code in (200, 201, 204): pattern.

3. Capture Patterns (Variable Binding)

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

def process_value(data):
    match data:
        case 0:
            print("Zero")
        case value:
            print(f"Got non-zero value: {value}")
 
process_value(42)  # Got non-zero value: 42

Important trap: A bare name always captures — it does not compare against an existing variable. This is the most common mistake:

# WRONG — this does NOT compare against expected_code
expected_code = 200
match response_code:
    case expected_code:  # This captures response_code into expected_code!
        print("Match")
 
# RIGHT — use a guard or literal
match response_code:
    case code if code == expected_code:
        print("Match")

To match against constants, use dotted names (like http.HTTPStatus.OK) or guard clauses.

4. Sequence Patterns (Lists and Tuples)

Destructure lists and tuples directly in the pattern:

def process_point(point):
    match point:
        case (0, 0):
            return "Origin"
        case (x, 0):
            return f"On x-axis at {x}"
        case (0, y):
            return f"On y-axis at {y}"
        case (x, y):
            return f"Point at ({x}, {y})"
 
print(process_point((3, 0)))   # On x-axis at 3
print(process_point((2, 5)))   # Point at (2, 5)

You can use *rest to capture remaining elements:

def first_and_rest(items):
    match items:
        case []:
            return "Empty list"
        case [single]:
            return f"Single item: {single}"
        case [first, *rest]:
            return f"First: {first}, remaining: {rest}"
 
print(first_and_rest([1, 2, 3, 4]))  # First: 1, remaining: [2, 3, 4]

5. Mapping Patterns (Dictionaries)

Match against dictionary keys and destructure values:

def handle_event(event):
    match event:
        case {"type": "click", "x": x, "y": y}:
            print(f"Click at ({x}, {y})")
        case {"type": "keypress", "key": key}:
            print(f"Key pressed: {key}")
        case {"type": "scroll", "direction": direction}:
            print(f"Scroll {direction}")
        case _:
            print("Unknown event")
 
handle_event({"type": "click", "x": 100, "y": 200})
# Click at (100, 200)
 
handle_event({"type": "keypress", "key": "Enter", "modifiers": ["Ctrl"]})
# Key pressed: Enter  (extra keys are ignored)

Mapping patterns match if the specified keys are present — extra keys in the dictionary are silently ignored. This makes them ideal for processing JSON data or API responses.

6. Class Patterns

Match against class instances and extract attributes:

from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
 
@dataclass
class Circle:
    center: Point
    radius: float
 
@dataclass
class Rectangle:
    corner: Point
    width: float
    height: float
 
def describe_shape(shape):
    match shape:
        case Circle(center=Point(x=0, y=0), radius=r):
            return f"Circle at origin with radius {r}"
        case Circle(center=center, radius=r):
            return f"Circle at ({center.x}, {center.y}) with radius {r}"
        case Rectangle(width=w, height=h) if w == h:
            return f"Square with side {w}"
        case Rectangle(width=w, height=h):
            return f"Rectangle {w}x{h}"
 
print(describe_shape(Circle(Point(0, 0), 5)))
# Circle at origin with radius 5
 
print(describe_shape(Rectangle(Point(1, 1), 4, 4)))
# Square with side 4

7. Guard Clauses

Add if conditions to any pattern for additional filtering:

def classify_number(n):
    match n:
        case n if n < 0:
            return "Negative"
        case 0:
            return "Zero"
        case n if n % 2 == 0:
            return "Positive even"
        case _:
            return "Positive odd"
 
print(classify_number(-5))  # Negative
print(classify_number(4))   # Positive even
print(classify_number(7))   # Positive odd

Guards let you add arbitrary conditions beyond what patterns can express. The pattern is matched first, then the guard condition is evaluated.

Real-World Example: JSON API Response Parser

One of the most practical uses of match-case is parsing API responses or JSON data:

import json
 
def process_api_response(response: dict) -> str:
    match response:
        case {"status": "success", "data": {"users": [*users]}}:
            return f"Found {len(users)} users"
 
        case {"status": "success", "data": {"user": {"name": name, "email": email}}}:
            return f"User: {name} ({email})"
 
        case {"status": "error", "code": 401}:
            return "Authentication required"
 
        case {"status": "error", "code": code, "message": msg}:
            return f"Error {code}: {msg}"
 
        case {"status": "error"}:
            return "Unknown error"
 
        case _:
            return "Unrecognized response format"
 
# Test with different responses
print(process_api_response({
    "status": "success",
    "data": {"users": ["Alice", "Bob", "Charlie"]}
}))
# Found 3 users
 
print(process_api_response({
    "status": "error",
    "code": 429,
    "message": "Rate limit exceeded"
}))
# Error 429: Rate limit exceeded

This replaces deeply nested if/elif chains with isinstance() checks and dictionary .get() calls. The match-case version states the expected structure directly.

Real-World Example: Command-Line Argument Parser

import sys
 
def parse_args(args: list[str]):
    match args:
        case [program]:
            print(f"Usage: {program} <command> [options]")
 
        case [_, "init", project_name]:
            print(f"Initializing project: {project_name}")
 
        case [_, "init"]:
            print("Error: project name required")
 
        case [_, "build", "--release"]:
            print("Building in release mode")
 
        case [_, "build"]:
            print("Building in debug mode")
 
        case [_, "test", *test_files] if test_files:
            print(f"Running tests: {', '.join(test_files)}")
 
        case [_, "test"]:
            print("Running all tests")
 
        case [_, unknown, *_]:
            print(f"Unknown command: {unknown}")
 
parse_args(["app", "init", "my-project"])  # Initializing project: my-project
parse_args(["app", "test", "a.py", "b.py"])  # Running tests: a.py, b.py
parse_args(["app", "build", "--release"])  # Building in release mode

Pre-3.10 Alternatives: Switch-Case Without match-case

If you are using Python 3.9 or earlier, or if match-case is overkill for simple value matching, here are the established alternatives.

if/elif/else Chains

The most straightforward approach:

def handle_command(command):
    if command == "quit":
        print("Exiting program")
    elif command == "save":
        print("Saving file")
    elif command == "load":
        print("Loading file")
    elif command == "help":
        print("Showing help")
    else:
        print(f"Unknown command: {command}")

When to use: When you have fewer than 5-6 cases and the logic is simple.

Dictionary Dispatch

Map values to functions using a dictionary:

def cmd_quit():
    print("Exiting program")
 
def cmd_save():
    print("Saving file")
 
def cmd_help():
    print("Showing help")
 
commands = {
    "quit": cmd_quit,
    "save": cmd_save,
    "help": cmd_help,
}
 
def handle_command(command):
    action = commands.get(command)
    if action:
        action()
    else:
        print(f"Unknown command: {command}")

When to use: When mapping values to simple actions, especially with many cases. O(1) lookup vs O(n) for if/elif.

Dictionary Dispatch with Arguments

def calculate(operation, a, b):
    ops = {
        "+": lambda a, b: a + b,
        "-": lambda a, b: a - b,
        "*": lambda a, b: a * b,
        "/": lambda a, b: a / b if b != 0 else float("inf"),
    }
    func = ops.get(operation)
    if func is None:
        raise ValueError(f"Unknown operation: {operation}")
    return func(a, b)
 
print(calculate("+", 10, 3))  # 13
print(calculate("/", 10, 0))  # inf

Enum-Based Dispatch

For typed, self-documenting code:

from enum import Enum
 
class Direction(Enum):
    NORTH = "north"
    SOUTH = "south"
    EAST = "east"
    WEST = "west"
 
def move(direction: Direction, x: int, y: int) -> tuple[int, int]:
    offsets = {
        Direction.NORTH: (0, 1),
        Direction.SOUTH: (0, -1),
        Direction.EAST: (1, 0),
        Direction.WEST: (-1, 0),
    }
    dx, dy = offsets[direction]
    return (x + dx, y + dy)
 
print(move(Direction.NORTH, 0, 0))  # (0, 1)

Comparison: Which Approach to Use

ApproachPython VersionBest ForComplexityPerformance
match-case3.10+Complex patterns, destructuring, type matchingHigh expressivenessComparable to if/elif
if/elif/elseAllSimple value comparisons, < 6 casesSimpleO(n) linear scan
Dict dispatchAllMany cases, value → function mappingMediumO(1) lookup
Dict + lambdaAllMany cases, value → expression mappingMediumO(1) lookup
Enum dispatchAllTyped constants, exhaustive matchingMediumO(1) lookup

Rule of thumb:

  • Use match-case when you need destructuring, type matching, or complex patterns
  • Use if/elif for simple comparisons with few branches
  • Use dict dispatch for many simple value-to-action mappings
  • Use enum dispatch when the set of values is fixed and typed

Common Patterns and Recipes

Type-Based Dispatch

def serialize(value):
    match value:
        case bool():  # Must come before int — bool is a subclass of int
            return "true" if value else "false"
        case int() | float():
            return str(value)
        case str():
            return f'"{value}"'
        case list():
            items = ", ".join(serialize(v) for v in value)
            return f"[{items}]"
        case dict():
            pairs = ", ".join(
                f'{serialize(k)}: {serialize(v)}' for k, v in value.items()
            )
            return "{" + pairs + "}"
        case None:
            return "null"
        case _:
            raise TypeError(f"Cannot serialize {type(value)}")
 
print(serialize({"name": "Alice", "scores": [95, 87, 92], "active": True}))
# {"name": "Alice", "scores": [95, 87, 92], "active": true}

Important: Put bool() before int() because True and False are instances of int in Python.

Nested Pattern Matching

def evaluate(expr):
    """Simple math expression evaluator."""
    match expr:
        case int(n) | float(n):
            return n
        case ("+", left, right):
            return evaluate(left) + evaluate(right)
        case ("-", left, right):
            return evaluate(left) - evaluate(right)
        case ("*", left, right):
            return evaluate(left) * evaluate(right)
        case ("/", left, right):
            divisor = evaluate(right)
            if divisor == 0:
                raise ZeroDivisionError("Division by zero")
            return evaluate(left) / divisor
        case ("neg", operand):
            return -evaluate(operand)
        case _:
            raise ValueError(f"Invalid expression: {expr}")
 
# (3 + 4) * 2
result = evaluate(("*", ("+", 3, 4), 2))
print(result)  # 14

State Machine with match-case

def tokenize(text):
    """Simple tokenizer using match-case as a state machine."""
    tokens = []
    i = 0
    while i < len(text):
        match text[i]:
            case ' ' | '\t' | '\n':
                i += 1  # Skip whitespace
            case '+' | '-' | '*' | '/':
                tokens.append(("OP", text[i]))
                i += 1
            case '(' | ')':
                tokens.append(("PAREN", text[i]))
                i += 1
            case c if c.isdigit():
                j = i
                while j < len(text) and text[j].isdigit():
                    j += 1
                tokens.append(("NUM", int(text[i:j])))
                i = j
            case c if c.isalpha():
                j = i
                while j < len(text) and text[j].isalnum():
                    j += 1
                tokens.append(("ID", text[i:j]))
                i = j
            case c:
                raise SyntaxError(f"Unexpected character: {c}")
    return tokens
 
print(tokenize("x + 42 * (y - 3)"))
# [('ID', 'x'), ('OP', '+'), ('NUM', 42), ('OP', '*'), ('PAREN', '('),
#  ('ID', 'y'), ('OP', '-'), ('NUM', 3), ('PAREN', ')')]

Performance: match-case vs if/elif vs dict

For simple value matching, all three approaches are fast enough that the difference does not matter in practice. Here are benchmark results for matching against 10 string values:

import timeit
 
commands = ["cmd_0", "cmd_1", "cmd_2", "cmd_3", "cmd_4",
            "cmd_5", "cmd_6", "cmd_7", "cmd_8", "cmd_9"]
 
# if/elif approach
def if_elif_dispatch(cmd):
    if cmd == "cmd_0": return 0
    elif cmd == "cmd_1": return 1
    elif cmd == "cmd_2": return 2
    elif cmd == "cmd_3": return 3
    elif cmd == "cmd_4": return 4
    elif cmd == "cmd_5": return 5
    elif cmd == "cmd_6": return 6
    elif cmd == "cmd_7": return 7
    elif cmd == "cmd_8": return 8
    elif cmd == "cmd_9": return 9
    else: return -1
 
# dict dispatch approach
dispatch_dict = {f"cmd_{i}": i for i in range(10)}
def dict_dispatch(cmd):
    return dispatch_dict.get(cmd, -1)
 
# match-case approach
def match_dispatch(cmd):
    match cmd:
        case "cmd_0": return 0
        case "cmd_1": return 1
        case "cmd_2": return 2
        case "cmd_3": return 3
        case "cmd_4": return 4
        case "cmd_5": return 5
        case "cmd_6": return 6
        case "cmd_7": return 7
        case "cmd_8": return 8
        case "cmd_9": return 9
        case _: return -1
ApproachBest case (first match)Worst case (last match)Miss (no match)
if/elif~80ns~400ns~420ns
dict dispatch~60ns~60ns~65ns
match-case~90ns~450ns~470ns

Key takeaway: Dictionary dispatch has O(1) constant time regardless of position. Both if/elif and match-case are O(n) linear scan. For fewer than ~20 cases, the difference is negligible. Choose based on readability, not performance.

Common Mistakes and How to Avoid Them

Mistake 1: Using match-case Before Python 3.10

# This causes SyntaxError in Python 3.9 and earlier
match command:  # SyntaxError: invalid syntax
    case "quit":
        pass

Check your Python version: python --version. If you are on 3.9 or earlier, use if/elif or dict dispatch.

Mistake 2: Comparing Against Variables

# WRONG — captures, does NOT compare
STATUS_OK = 200
match response_code:
    case STATUS_OK:  # This always matches and overwrites STATUS_OK!
        print("OK")
 
# RIGHT — use dotted name
class Status:
    OK = 200
    NOT_FOUND = 404
 
match response_code:
    case Status.OK:
        print("OK")
    case Status.NOT_FOUND:
        print("Not Found")
 
# RIGHT — use a guard
match response_code:
    case code if code == STATUS_OK:
        print("OK")

Mistake 3: Forgetting the Wildcard Default

# DANGEROUS — unmatched values silently do nothing
match command:
    case "save":
        save_file()
    case "load":
        load_file()
    # No default — "delete" silently falls through with no action
 
# SAFE — always include a default case
match command:
    case "save":
        save_file()
    case "load":
        load_file()
    case _:
        raise ValueError(f"Unknown command: {command}")

Mistake 4: Wrong Order With Overlapping Patterns

# WRONG — the first case catches everything
match value:
    case x:            # Captures ANY value — always matches!
        print(f"Got: {x}")
    case 42:           # Never reached
        print("The answer")
 
# RIGHT — specific patterns first, general last
match value:
    case 42:
        print("The answer")
    case x:
        print(f"Got: {x}")

Using match-case in Data Science

If you work with data processing pipelines, match-case is useful for handling different data formats and cleaning operations:

import csv
from pathlib import Path
 
def load_data(source):
    """Load data from different source types."""
    match source:
        case str() as path if path.endswith(".csv"):
            with open(path) as f:
                return list(csv.DictReader(f))
        case str() as path if path.endswith(".json"):
            import json
            with open(path) as f:
                return json.load(f)
        case list() as records:
            return records
        case dict() as single_record:
            return [single_record]
        case _:
            raise TypeError(f"Unsupported data source: {type(source)}")

For interactive data exploration, tools like PyGWalker (opens in a new tab) let you visualize DataFrames directly without writing plotting code — useful when you want to quickly inspect the data your processing pipeline produces.

If you are building data processing scripts that use match-case for complex branching, RunCell (opens in a new tab) provides an AI-powered Jupyter environment where you can test pattern matching interactively with real data samples.

match-case With Dataclasses and Named Tuples

Python's match-case works especially well with dataclasses and named tuples:

from dataclasses import dataclass
from typing import Optional
 
@dataclass
class LogEntry:
    level: str
    message: str
    error: Optional[Exception] = None
 
def handle_log(entry: LogEntry):
    match entry:
        case LogEntry(level="CRITICAL", error=err) if err is not None:
            send_alert(f"CRITICAL with error: {err}")
            restart_service()
        case LogEntry(level="CRITICAL", message=msg):
            send_alert(f"CRITICAL: {msg}")
        case LogEntry(level="ERROR", error=err) if err is not None:
            log_to_file(f"ERROR: {err}")
        case LogEntry(level="WARNING" | "ERROR", message=msg):
            log_to_file(msg)
        case LogEntry(level="INFO" | "DEBUG"):
            pass  # Ignore low-priority logs

Frequently Asked Questions

Does Python have a switch statement?

Python 3.10+ has the match-case statement, which is Python's version of switch-case. It goes beyond traditional switch with structural pattern matching — you can destructure objects, match patterns, and bind variables. For Python 3.9 and earlier, use if/elif/else chains or dictionary dispatch as alternatives.

What is the difference between match-case and switch-case?

Python's match-case has no fall-through (no break needed), supports pattern matching (sequences, mappings, classes), can destructure and bind variables from matched patterns, and supports guard clauses with if conditions. Traditional switch-case in C/Java only matches literal values.

Is match-case faster than if/elif in Python?

For simple value matching, they perform similarly — both scan linearly. Dictionary dispatch is faster (O(1)) for many cases. The performance difference is negligible for fewer than 20 cases. Choose based on readability and pattern complexity, not performance.

Can I use match-case with Python 3.9?

No. The match-case statement requires Python 3.10 or later. For earlier versions, use if/elif/else chains or dictionary dispatch.

Why does my match-case variable comparison not work?

A bare name like case x: captures any value into x — it does not compare against an existing variable. To compare against a variable, use a guard clause (case val if val == x:) or dotted names (case MyClass.CONSTANT:).

What is the underscore _ in match-case?

The _ wildcard pattern matches any value without binding it to a variable. It is the default case — like default: in switch statements. Always place it last.

Related Guides

📚