Modern Python Intermediate #6: Pattern matching in depth

4 min read

We briefly saw match-case in Basics #3simple 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 #

KindFormMeaning
Literalcase 200:exact value match
Capturecase x:matches anything and binds to a variable
Sequencecase [a, b, *rest]:list/tuple shape
Mappingcase {"key": v}:dict shape
Classcase 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 ==.

Literal
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.

🚫 Interpreted as capture
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.

✅ Constant comparison via dotted name
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.

Enum is the cleanest
from enum import Enum

class Status(Enum):
    SUCCESS = 200
    NOT_FOUND = 404

match status:
    case Status.SUCCESS:
        ...
    case Status.NOT_FOUND:
        ...

Capture variables #

Capture
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 #

🚫 Same name twice in one pattern
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.

✅ With 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.

Sequence 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).

Strings don't match sequence patterns
match "hello":
    case [a, *rest]:
        print(a, rest)    # doesn't match
    case "hello":
        print("hello!")   # this branch

Fixed 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.

Mapping pattern
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 #

Capture the rest
match config:
    case {"host": host, "port": port, **rest}:
        print(f"{host}:{port}, 그 외 옵션: {rest}")

Keys themselves must be literals #

🚫 Keys can't be captured
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 matching — basics
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__.

__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.

dataclass + 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.

Built-in matching
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.

as
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 — | #

OR
match status:
    case 200 | 201 | 204:
        print("성공")
    case 400 | 404 | 422:
        print("클라이언트 오류")

In OR patterns, every alternative must capture the same variables.

🚫 Different captures in OR
match val:
    case [x] | [x, y]:    # ✗ y not in the first alternative
        ...
✅ Same variables only
match val:
    case [x] | [x, _]:    # both alternatives only capture x
        ...

Guards — if #

We saw this briefly in Basics #3.

Guard
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("그 외")    # default

It can also appear in many positions without conflict.

Multiple _
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.

Real-world — branching API responses
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.

🚫 Forced match
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/elif is more natural
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, **rest for the rest
  • Class patterns: positional matching with __match_args__; dataclass generates it
  • Built-in matching: int(n), str(s) form
  • as to capture the entire value
  • | for OR (every alternative captures the same variables)
  • Guard if combines 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.

X