|$ curl https://forge-ai.dev/api/markdown?path=docs/python/match-case
$cat docs/match-/-case.md
updated Today·18-24 min read·published
match / case
Introduction
Structural pattern matching (match/case, PEP 634) destructures data by shape — not just equality switches. Available in Python 3.10+.
ℹ
info
Patterns can bind names. A bare name like case x: matches anything and binds x — put it last.
Literal & Or Patterns
literals.py
Python
| 1 | def handle(code: int) -> str: |
| 2 | match code: |
| 3 | case 200 | 201 | 204: |
| 4 | return "ok" |
| 5 | case 301 | 302: |
| 6 | return "redirect" |
| 7 | case 400 | 404 | 422: |
| 8 | return "client" |
| 9 | case 500: |
| 10 | return "server" |
| 11 | case _: |
| 12 | return "other" |
| 13 | |
| 14 | print(handle(201), handle(404), handle(418)) |
Guards
guards.py
Python
| 1 | def classify(n: int) -> str: |
| 2 | match n: |
| 3 | case x if x < 0: |
| 4 | return "negative" |
| 5 | case 0: |
| 6 | return "zero" |
| 7 | case x if x % 2 == 0: |
| 8 | return "even" |
| 9 | case _: |
| 10 | return "odd" |
| 11 | |
| 12 | print([classify(i) for i in (-1, 0, 2, 3)]) |
Sequence Patterns
sequence.py
Python
| 1 | def parse(cmd: list[str]) -> str: |
| 2 | match cmd: |
| 3 | case ["quit"]: |
| 4 | return "bye" |
| 5 | case ["load", filename]: |
| 6 | return f"load {filename}" |
| 7 | case ["move", x, y]: |
| 8 | return f"move to {x},{y}" |
| 9 | case ["go", *rest] if rest: |
| 10 | return "go " + " ".join(rest) |
| 11 | case [head, *mid, tail]: |
| 12 | return f"{head}..{tail} ({len(mid)} mid)" |
| 13 | case _: |
| 14 | return "unknown" |
| 15 | |
| 16 | print(parse(["load", "a.txt"])) |
| 17 | print(parse(["go", "north", "fast"])) |
| 18 | print(parse(["a", "b", "c", "d"])) |
Mapping Patterns
mapping.py
Python
| 1 | def handle_event(event: dict) -> str: |
| 2 | match event: |
| 3 | case {"type": "click", "x": int(x), "y": int(y)}: |
| 4 | return f"click {x},{y}" |
| 5 | case {"type": "key", "key": str(k)} if len(k) == 1: |
| 6 | return f"char {k}" |
| 7 | case {"type": "key", "key": k}: |
| 8 | return f"key {k}" |
| 9 | case {"type": t, **rest}: |
| 10 | return f"other {t} keys={list(rest)}" |
| 11 | case _: |
| 12 | return "invalid" |
| 13 | |
| 14 | print(handle_event({"type": "click", "x": 1, "y": 2})) |
| 15 | print(handle_event({"type": "key", "key": "Enter"})) |
📝
note
Mapping patterns ignore extra keys unless you capture them with **rest.
Class Patterns
class_patterns.py
Python
| 1 | from dataclasses import dataclass |
| 2 | |
| 3 | @dataclass |
| 4 | class Point: |
| 5 | x: int |
| 6 | y: int |
| 7 | |
| 8 | @dataclass |
| 9 | class Circle: |
| 10 | center: Point |
| 11 | radius: int |
| 12 | |
| 13 | def describe(shape) -> str: |
| 14 | match shape: |
| 15 | case Point(x=0, y=0): |
| 16 | return "origin" |
| 17 | case Point(x, y) if x == y: |
| 18 | return f"diagonal {x}" |
| 19 | case Point(x, y): |
| 20 | return f"point {x},{y}" |
| 21 | case Circle(center=Point(0, 0), radius=r): |
| 22 | return f"unit-centered r={r}" |
| 23 | case Circle(center=Point(x, y), radius=r): |
| 24 | return f"circle at {x},{y} r={r}" |
| 25 | case _: |
| 26 | return "unknown" |
| 27 | |
| 28 | print(describe(Point(0, 0))) |
| 29 | print(describe(Circle(Point(1, 2), 5))) |
Class patterns use __match_args__ for positional capture. Dataclasses set this automatically.
match_args.py
Python
| 1 | class Pair: |
| 2 | __match_args__ = ("left", "right") |
| 3 | def __init__(self, left, right): |
| 4 | self.left = left |
| 5 | self.right = right |
| 6 | |
| 7 | def mid(p): |
| 8 | match p: |
| 9 | case Pair(a, b): |
| 10 | return (a + b) / 2 |
| 11 | |
| 12 | print(mid(Pair(2, 4))) |
as Patterns & Capture
as_pat.py
Python
| 1 | def normalize(value): |
| 2 | match value: |
| 3 | case {"error": msg as m}: |
| 4 | return f"ERR:{m}" |
| 5 | case [x, y] as pair: |
| 6 | return ("pair", pair, x + y) |
| 7 | case str() as s if s.strip(): |
| 8 | return s.strip() |
| 9 | case _: |
| 10 | return None |
| 11 | |
| 12 | print(normalize({"error": "boom"})) |
| 13 | print(normalize([1, 2])) |
| 14 | print(normalize(" hi ")) |
match vs if/elif
| Use match when | Use if/elif when |
|---|---|
| Destructuring nested data | Simple boolean conditions |
| Multiple shapes / ADTs | Range checks without structure |
| JSON-like dict protocols | Comparisons with side effects |
| Command parsing | Early-return guard clauses |
✓
best practice
Do not rewrite every if-chain as match. Prefer match when the shape of data carries meaning.
Exhaustiveness Patterns
exhaust.py
Python
| 1 | from enum import Enum, auto |
| 2 | from typing import NoReturn |
| 3 | |
| 4 | class Color(Enum): |
| 5 | RED = auto() |
| 6 | GREEN = auto() |
| 7 | BLUE = auto() |
| 8 | |
| 9 | def assert_never(x: NoReturn) -> NoReturn: |
| 10 | raise AssertionError(f"unexpected: {x!r}") |
| 11 | |
| 12 | def hex_color(c: Color) -> str: |
| 13 | match c: |
| 14 | case Color.RED: |
| 15 | return "#f00" |
| 16 | case Color.GREEN: |
| 17 | return "#0f0" |
| 18 | case Color.BLUE: |
| 19 | return "#00f" |
| 20 | case _ as unreachable: |
| 21 | assert_never(unreachable) |
Pitfalls
- case x: always matches — order matters
- case [x]: matches length-1 sequences, not only lists
- Constants must be dotted (case Status.OK) or the name is a capture
- Guards run only after the pattern succeeds
- Subject is evaluated once — good for expensive expressions
pitfall.py
Python
| 1 | STATUS_OK = 200 |
| 2 | |
| 3 | def bad(code: int) -> str: |
| 4 | match code: |
| 5 | case STATUS_OK: # BUG: this CAPTURES into STATUS_OK locally in 3.10 style? |
| 6 | # Actually bare names in case are capture patterns! |
| 7 | return "ok" |
| 8 | case _: |
| 9 | return "other" |
| 10 | |
| 11 | def good(code: int) -> str: |
| 12 | match code: |
| 13 | case 200: |
| 14 | return "ok" |
| 15 | case _: |
| 16 | return "other" |
| 17 | |
| 18 | # Use dotted names for constants: |
| 19 | class C: |
| 20 | OK = 200 |
| 21 | |
| 22 | def better(code: int) -> str: |
| 23 | match code: |
| 24 | case C.OK: |
| 25 | return "ok" |
| 26 | case _: |
| 27 | return "other" |
✕
danger
A bare name in a case clause is a capture pattern, not a constant lookup. Use literals or dotted attributes for constants.
Nested & Real-World JSON
nested.py
Python
| 1 | def handle_api(payload: dict) -> str: |
| 2 | match payload: |
| 3 | case {"data": {"user": {"id": int(uid), "name": str(name)}}}: |
| 4 | return f"user {uid}:{name}" |
| 5 | case {"errors": [{"message": msg}, *_]}: |
| 6 | return f"error {msg}" |
| 7 | case {"data": list(items)} if items: |
| 8 | return f"{len(items)} items" |
| 9 | case _: |
| 10 | return "unrecognized" |
| 11 | |
| 12 | print(handle_api({"data": {"user": {"id": 1, "name": "Ada"}}})) |
| 13 | print(handle_api({"errors": [{"message": "nope"}]})) |
Combining with Assignment Expressions
combo_walrus.py
Python
| 1 | import re |
| 2 | |
| 3 | def extract(line: str) -> str | None: |
| 4 | match line.split(): |
| 5 | case ["ERR", code] if (m := re.fullmatch(r"E\d{3}", code)): |
| 6 | return m.group(0) |
| 7 | case ["INFO", *msg]: |
| 8 | return " ".join(msg) |
| 9 | case _: |
| 10 | return None |
| 11 | |
| 12 | print(extract("ERR E404")) |
| 13 | print(extract("INFO hello world")) |
Matching Enums
enum_match.py
Python
| 1 | from enum import Enum, auto |
| 2 | |
| 3 | class Cmd(Enum): |
| 4 | START = auto() |
| 5 | STOP = auto() |
| 6 | RESTART = auto() |
| 7 | |
| 8 | def run(cmd: Cmd) -> str: |
| 9 | match cmd: |
| 10 | case Cmd.START: |
| 11 | return "starting" |
| 12 | case Cmd.STOP: |
| 13 | return "stopping" |
| 14 | case Cmd.RESTART: |
| 15 | return "restarting" |
| 16 | |
| 17 | print(run(Cmd.START)) |
$Blueprint — Engineering Documentation·Section ID: PYTHON-MATCH·Revision: 1.0
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.