Skip to content

← Modern Syntax and Modernisation step 11 of 18

Medium Primitives

match/case fundamentals: literal, capture, wildcard

match looks like a switch statement and is not one. The gap between those two mental models produces one specific, silent, expensive bug:

OK = 200
NOT_FOUND = 404

def classify(status: int) -> str:
    match status:
        case OK:            # <-- NOT a comparison
            return "ok"
        case NOT_FOUND:     # <-- dead code, forever
            return "missing"

case OK: is a capture pattern. It matches anything, binds the subject to the name OK — shadowing the module constant for the rest of the function — and returns "ok". Every later case is unreachable. There is no exception, no warning, and every test that happens to pass a 200 goes green.

The rules that actually matter

match and case are soft keywords. match = re.match(...) still works; the parser decides from context. That is why the language could not simply reserve case NAME as a comparison.

A bare name is always a capture. To compare against a constant the pattern must contain a dot — case Code.OK:, case http.NOT_FOUND:. This is a deliberate design decision (PEP 634 §”Value patterns”): the alternative, making capture require a sigil, would have made the common case noisy. The practical consequence is that enums are the natural constant vocabulary for match, because their members are always dotted.

Literal patterns use ==, except True/False/None, which use is. So case True: does not match 1, but case 1: does match True, because 1 == True. Order the blocks accordingly.

At most one irrefutable pattern, and it must be last. A bare capture or _ after another case is a SyntaxError — but only when the compiler can see it is irrefutable. case x if x > 3: is refutable (the guard can fail) and may appear anywhere.

What you are building

def classify(status: int | str) -> str
def solve(statuses: list[int | str]) -> list[str]

solve maps classify over the list. classify returns:

subject result
True / False "enabled" / "disabled"
1 / 0 "one" / "zero"
Code.OK (200) "ok"
Code.NOT_FOUND (404) "missing"
Code.TEAPOT (418) "teapot"
204 "no-content"
any other int >= 500 "server-error"
"retry" "retry"
any other str s "text:{s}"
anything else "other"

The Code enum is given. The test data is chosen so that a solution using bare names instead of dotted value patterns gets the first case right and every subsequent case wrong — which is exactly how this bug reaches production.

The static defence

mypy’s --warn-unreachable reports the dead branches a stray capture creates: Statement is unreachable. It is not part of --strict (strict is thirteen flags and this is not one of them), but it is part of this course’s Silver tier, and it is the cheapest possible insurance against the trap above. Turn it on in any module that uses match.