Modern Python Intermediate #6: Pattern matching in depth
We briefly saw match-case in Basics #3 — simple branching, OR patterns, destructuring, class matching, and guards. This post goes a step further. It organizes every pattern category in one place, covers integration points like __match_args__ for user-defined classes, and walks through the common pitfalls.
Five pattern categories #
| Kind | Form | Meaning |
|---|---|---|
| Literal | case 200: | exact value match |
| Capture | case x: | matches anything and binds to a variable |
| Sequence | case [a, b, *rest]: | list/tuple shape |
| Mapping | case {"key": v}: | dict shape |
| Class | case Point(x=0): | class + attribute match |
These five compose to express almost any case. Let’s go deep on each.
Literal and value comparison #
Literal patterns compare with ==.
match status:
case 200:
print("OK")
case "active":
print("active")
case True:
print("yes")
case None:
print("none")Dotted name — comparing constants #
Writing case ABC: plain becomes a capture variable. To compare against a constant, a dot (.) must appear.
SUCCESS = 200
match code:
case SUCCESS: # ⚠ binds code to a variable named SUCCESS!
print("ok")
case _:
print("else")SUCCESS matches every value, so the second case never runs. Tools warn about this.
class Status:
SUCCESS = 200
NOT_FOUND = 404
match code:
case Status.SUCCESS:
print("ok")
case Status.NOT_FOUND:
print("not found")When using constants in matching, putting them on an enum or class as attributes is the safe pattern.
from enum import Enum
class Status(Enum):
SUCCESS = 200
NOT_FOUND = 404
match status:
case Status.SUCCESS:
...
case Status.NOT_FOUND:
...Capture variables #
match command:
case x:
print(f"받은 명령: {x}")A lowercase identifier (more precisely, an identifier without a dot) is a capture. Matches everything and binds to the variable. _ (underscore) is the exception — wildcard, doesn’t bind.
A capture variable can’t appear twice #
match (a, b):
case (x, x): # ✗ SyntaxError
...Using the same variable name twice in one pattern is an error. To check for equality, use a guard.
match (a, b):
case (x, y) if x == y:
...Sequence patterns #
You can use the shape of a list or tuple as a pattern.
match items:
case []:
print("빈 리스트")
case [x]:
print(f"하나만: {x}")
case [x, y]:
print(f"둘: {x}, {y}")
case [first, *rest]:
print(f"머리 {first}, 나머지 {rest}")
case [*init, last]:
print(f"앞 {init}, 마지막 {last}")
case [first, *middle, last]:
print(f"{first}, {middle}, {last}")*name collects the rest at once — same as regular unpacking. Matching accepts both list and tuple. str and bytes are sequences but excluded from matching (to prevent unintended matches).
match "hello":
case [a, *rest]:
print(a, rest) # doesn't match
case "hello":
print("hello!") # this branchFixed length vs variable #
[a, b, c] — exactly length 3
[a, b, *rest] — 3 or more
[*items] — any length (effectively the same as a capture)
Mapping patterns #
A partial match on a dict. If the listed keys are present, it matches; other keys are ignored.
event = {"type": "click", "x": 10, "y": 20, "extra": "ignored"}
match event:
case {"type": "click", "x": x, "y": y}:
print(f"({x}, {y})")
case {"type": "key", "code": code}:
print(f"key {code}")Mapping patterns are partial matches. {"type": "click"} is “the dict has a type key with value click, OK.” Other keys can exist.
**rest to capture the rest
#
match config:
case {"host": host, "port": port, **rest}:
print(f"{host}:{port}, 그 외 옵션: {rest}")Keys themselves must be literals #
match data:
case {key: value}: # ✗ key is not a capture
...Keys in mapping patterns must be literals or dotted names. They can’t be captured.
Class patterns #
The form briefly seen in Basics #3:
class Circle:
def __init__(self, radius: float):
self.radius = radius
match shape:
case Circle(radius=r):
print(f"원, 반지름 {r}")Match by keyword arguments. Use the attribute name.
__match_args__ — positional matching
#
To match by positional arguments like Point(0, 0), the class must declare __match_args__.
class Point:
__match_args__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
match p:
case Point(0, 0):
print("원점")
case Point(0, y):
print(f"y축 위, y={y}")
case Point(x, 0):
print(f"x축 위, x={x}")
case Point(x, y):
print(f"({x}, {y})")@dataclass generates this automatically
#
@dataclass from #1 builds __match_args__ automatically. Pattern matching and dataclasses are a perfect match.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
match p:
case Point(0, 0): # works automatically
print("원점")Built-ins also match — but with a single argument #
Built-ins like int, str, bool match the value itself with a single position.
match x:
case int(n):
print(f"정수 {n}")
case str(s):
print(f"문자열 {s}")
case list() if not x: # empty list
print("빈 리스트")int(n) means “if the value is int, capture into n.”
Capture vs comparison — the as keyword
#
Use as when you want to capture the entire matched value into a variable.
match shape:
case Circle() as c:
register(c)
case Square(side=s) as sq if s > 10:
register(sq)Useful when you need to refer to the original value once more.
OR pattern — |
#
match status:
case 200 | 201 | 204:
print("성공")
case 400 | 404 | 422:
print("클라이언트 오류")In OR patterns, every alternative must capture the same variables.
match val:
case [x] | [x, y]: # ✗ y not in the first alternative
...match val:
case [x] | [x, _]: # both alternatives only capture x
...Guards — if
#
We saw this briefly in Basics #3.
match (x, y):
case (a, b) if a == b:
...
case (a, b) if a > b:
...Guards combine with every pattern category. They check additional conditions on captured variables.
Wildcard — _
#
_ matches without capturing. case _: is the default position.
match status:
case 200:
print("OK")
case _:
print("그 외") # defaultIt can also appear in many positions without conflict.
match items:
case [_, _, third]:
print(f"세 번째: {third}")x, x isn’t allowed but _, _ is — _ doesn’t capture.
Combined example — JSON handling #
A case where you branch on the shape of an API response.
def handle(response: dict) -> str:
match response:
# success — has data, with a fixed type shape
case {"status": "ok", "data": {"items": list(items), "total": int(total)}}:
return f"받음: {len(items)}개 / 전체 {total}"
# paginated
case {"status": "ok", "data": {"items": items, "next_cursor": str(cursor)}}:
return f"다음 페이지 cursor: {cursor}"
# empty result
case {"status": "ok", "data": {"items": []}}:
return "결과 없음"
# error — code + message
case {"status": "error", "code": int(code), "message": str(msg)}:
return f"에러 {code}: {msg}"
# everything else
case _:
return "알 수 없는 응답"Doing the same job with if/elif scatters type checking, key existence checks, and value capture across many lines. match collapses all three into one.
Where match-case doesn’t fit
#
match-case is not a panacea. Plain if statements read better in many situations.
match score:
case s if s >= 90:
grade = "A"
case s if s >= 80:
grade = "B"
case s if s >= 70:
grade = "C"
case _:
grade = "F"if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"The rule of thumb: branching by shape vs branching by value range. When the shape is what matters, reach for match; for simple comparisons, if/elif is usually better.
Wrap-up #
What this post covered:
- Five patterns — literal, capture, sequence, mapping, class
- Use dotted names (
Status.OK) for constant comparison; plain names are captures - Sequence:
[a, *rest, b]; str/bytes excluded from matching - Mapping: partial match, keys are literals,
**restfor the rest - Class patterns: positional matching with
__match_args__; dataclass generates it - Built-in matching:
int(n),str(s)form asto capture the entire value|for OR (every alternative captures the same variables)- Guard
ifcombines with any pattern _doesn’t capture; can appear in many positions- Match for shape branches, if/elif for value-range branches
In the next post (#7 Async intro) — the last in the intermediate series — we cover asyncio and async/await. The coroutine we briefly saw above takes center stage.