Since 3.10 there has been a steady drip of pull requests converting perfectly
good if/elif chains into match statements for no benefit, and a matching
drip of teams banning match outright after someone shipped the capture-pattern
trap. Both reactions come from not having a rule. Here is one.
The rule
match wins when the subject has structure that you want to destructure
and test at the same time.
That is the entire criterion. Everything below follows from it.
match event:
case {"kind": "http", "status": int() as s, "path": str() as p} if s >= 500:
alert(p)
One expression checks the shape, checks three types, extracts two values, and
applies a condition. The if/elif equivalent is six lines, repeats
event["status"] twice, and needs an isinstance before the comparison to
satisfy the type checker.
Where if/elif still wins
Boolean conditions on unrelated expressions. If your cases are
if user.is_admin, elif quota_exceeded(user), elif now > deadline — there
is no single subject. Writing match True: with guards is a well-known
anti-pattern that reads worse than what it replaced.
Two or three literal comparisons. if method == "GET": ... elif method == "POST": ... is clearer as it stands, and does not carry the capture-pattern
hazard.
Branches testing different variables. match has exactly one subject. If
half your branches look at request and half look at config, you will end up
matching on a tuple of the two, which obscures rather than reveals.
Dispatch on a key with no structure. A dict[str, Callable[[Event], None]]
is often better than either: it is data, so it can be extended by a plugin,
inspected in a test, and rendered in documentation.
💡A colleague replaces a nine-branch if/elif chain over command == "..." string literals with a nine-case match over the same literals, and argues it will be faster because match compiles to a jump table. Is that true?
click to reveal
No. CPython does not compile match to a jump table.
The compiler emits pattern-matching opcodes — MATCH_CLASS, MATCH_MAPPING, MATCH_SEQUENCE, MATCH_KEYS, plus ordinary comparisons — and evaluates cases in order, top to bottom, exactly like an if/elif chain. For a chain of bare literal patterns the bytecode is essentially the same comparisons in the same order. There is no hashing step and no computed goto over case values, and matching the last of nine cases costs about nine comparisons in both forms.
So the honest reasons to prefer match are readability and the exhaustiveness affordance (assert_never), not speed. If dispatch cost is genuinely on your profile, the answer is a dict lookup, which is O(1) — and which neither form gives you.
Reviewer heuristic that follows: if every case is a bare literal and nothing is destructured, you wrote a switch statement. Either leave the if/elif alone or promote it to a dict.
The two failure modes that get match banned
The capture-pattern trap. case MAX_RETRIES: is a capture, not a
comparison. It matches everything, shadows the constant, and makes every
subsequent case dead — with no error at runtime and no warning from
mypy --strict unless you have enabled --warn-unreachable. This is a real
design cost of the feature and the reason the mitigation is non-negotiable:
constants used in patterns live on an Enum or a namespace object, so they are
always dotted.
Mistaking match for validation. case {"type": "user"}: matches a
payload with forty extra keys, because mapping patterns are partial and the
language offers no way to demand an exact key set. Teams that used match as
their request-shape check and then found unexpected keys flowing through
concluded that match was unsafe. It is not unsafe; it is a dispatch construct
being asked to do a validator’s job.
💡Your team's linter has --warn-unreachable off because it produced false positives elsewhere in the codebase. What is the cheapest remaining defence against the capture-pattern trap?
click to reveal
Make it impossible to write, rather than detectable after the fact.
The trap needs a bare name in a case position. Remove the bare names: any constant that appears in a pattern lives on an Enum (case Status.PENDING:) or a namespace class (case Limits.MAX_RETRIES:). Both are dotted, so both are value patterns, and typo’ing the attribute is an immediate AttributeError at import rather than a silent always-match.
A cheap second layer is a grep-level lint: ruff does not currently have a dedicated rule, but a one-line case [A-Z_]+: search in CI catches the SCREAMING_CASE constants that are the common form of the bug. And assert_never in the final case gives you a third: if a stray capture has made every later case dead, the union is no longer Never at that point and mypy objects.
What does not work is code review alone. The bug is invisible: the code reads exactly like the correct version, and the tests for the first case pass.
A decision procedure
Ask in order:
-
Is there one subject? No →
if/elif. -
Does the subject have structure you need to take apart? No →
if/elif, or a dict. -
Is the set of shapes closed and worth enforcing? Yes →
matchwithassert_never, and now the compiler tells you when someone adds a variant. - Are the constants dotted? No → make them dotted first.
- Are you validating, or dispatching? Validating → use a validator.
Two more practical notes. match and case are soft keywords, so
match = re.match(...) still works and a variable called case is still
legal — no migration hazard there. And a match statement is one of the
easiest constructs to raise a function’s cognitive-complexity score with, so if
your team enforces max-complexity, a long match may need extracting into a
handler-per-shape regardless of which construct reads better.