Why does Python never tell you a match statement is missing a case?
answer
- Ask what a compiler would need to know
- The set of possible subjects is open
- Patterns are tried, never analysed
- Dotted names, subclasses, key subsets, guards
- Substitute a raising wildcard plus enum tests
basics
~20 sBecause Python is dynamically typed and its patterns resolve at runtime: value patterns are name lookups, class patterns admit any subclass, and guards are arbitrary expressions. There is no closed set of cases to check against.
solid answer
~50 sExhaustiveness analysis needs two things Python does not have at the point where `match` executes: a known, closed set of possible subjects, and patterns whose coverage can be decided without running them. The subject is a dynamically typed object that may be any type at all; a value pattern is a dotted name resolved when the case is tried, so its target can change between runs; a class pattern matches any subclass, including ones imported later; a mapping pattern matches on a subset of keys, so "all mappings" is not an enumerable thing; and a guard is a full Python expression. Cases are *tried*, never analysed. Closing the gap is the author's job: model the domain as a closed set, end the statement with `case _:` that raises with the subject in the message, and test every member of that set.
code
python · 20 linesimport enum
class Plan(enum.Enum):
TRIAL = "trial"
PRO = "pro"
def monthly_cents(plan):
match plan:
case Plan.TRIAL:
return 0
case Plan.PRO:
return 9200
case _:
raise ValueError(f"unpriced plan: {plan!r}")
print(monthly_cents(Plan.PRO))
try:
monthly_cents("pro")
except ValueError as exc:
print("raised:", exc)go deeper
You are unlikely to be asked this, but know the consequence: Python will not warn you about a missing case, so a forgotten value silently does nothing at all.
Be able to name why the analysis is impossible at runtime — dynamic typing plus patterns that resolve names, admit subclasses, match key subsets and run arbitrary guards.
Demonstrate the production discipline: close the domain at the boundary, end every dispatch with a wildcard that raises with the subject interpolated, and test across the closed set rather than the branches.
Own the tradeoff between failing a whole run and skipping one record, decide it by who owns the input domain, and make the resulting convention explicit so dispatch sites across services behave the same way under surprise.
This question separates candidates who have used `match` from candidates who have reasoned about it. The short answer is that exhaustiveness checking requires a closed universe of subjects and patterns whose coverage is decidable, and Python's pattern grammar supplies neither at runtime. ### What would have to be true for the check to exist Languages that check exhaustiveness do so over closed sum types: the compiler knows a value is one of exactly three variants, so it can subtract the covered variants from that set and complain if anything remains. Python has no such construct in the runtime object model. A subject is an object; the set of objects is open. Even when *you* know the subject is one of two enum members, the interpreter does not — the parameter carries no runtime restriction, and the function will happily be called with a string that spells the same thing. ### Every pattern kind resists it **Value patterns** are dotted names, resolved when the case is tried. `case Plan.TRIAL:` reads the attribute at that moment; a module could rebind it between two executions of the same statement, so "which values does this case cover" has no fixed answer before the case runs. **Class patterns** are an `isinstance` test, so they match subclasses too — including subclasses defined in a module imported after this one. A case can start covering values that did not exist when the file was compiled. **Mapping patterns** match a *subset* of keys, so a mapping case is satisfied by an unbounded family of dicts. Subtracting that from "all possible subjects" is not an operation with a finite answer. **Guards** are ordinary Python expressions. Deciding whether a set of guards covers every case is deciding arbitrary Python, which is not a computation the interpreter is going to attempt on your behalf. And the design position reinforces the mechanics: PEP 634 never required exhaustiveness, and a match that matches nothing is a no-op by design, consistent with an `if`/`elif` chain with no `else`. So there is no version of Python where this warning appears — 3.10 through 3.14 all behave identically. An external static checker can prove exhaustiveness for code where the types are declared and closed, but that is a separate tool's analysis of your annotations, not something the interpreter does, and it says nothing about untyped input arriving at runtime. ### What it costs in production, and the discipline that replaces it The canonical failure: a nightly subscription-billing run dispatches on plan codes with a `match`, and the codes it knows are seeded from a plan table that is cached in the worker at startup. Someone adds a new plan; the cache is stale, so a plan code the `match` has never seen reaches the statement. No case matches. No exception. The invoice line for those accounts is never created, and the run reports success. It is caught weeks later, because the totals dashboard only alerts when a run drifts past its 92nd-percentile budget and a few hundred missing lines never came close to that line. Every ingredient of that failure is preventable without an exhaustiveness check: **Make the domain closed in your own code.** Convert the incoming string to an enum member at the boundary, and let *that* conversion be the thing that fails loudly on an unknown value. One well-placed conversion error beats a silent skip in every dispatch site downstream. **End the statement with a wildcard that raises.** `case _: raise ValueError(f"unpriced plan: {plan!r}")`. The `!r` matters — it distinguishes the enum member from a string that spells the same thing, which is usually the actual bug. This is the entire substitute for exhaustiveness checking, and it costs two lines. **Test the closure, not the branches.** Write a test that iterates every member of the enum, calls the function, and asserts no exception. It fails the day someone adds a member without adding a case, which is exactly the moment a compiler would have complained in another language. **Never let the catch-all be silent for a domain you own.** Logging and continuing is the right call for input arriving from outside your system, where new kinds are legitimate. For a set your own code defines, silence just moves the discovery of a bug from a stack trace to a quarterly reconciliation. ### The one-line summary Python tries patterns, it does not analyse them, and dynamic typing means there is no closed set to analyse against. The wildcard-that-raises is the language's answer, and it is the author's responsibility to write it.
- Why does `!r` in the catch-all's error message earn its place?Because the commonest cause of an unmatched subject is a type confusion that plain formatting hides. `unpriced plan: pro` could be the string `"pro"` or the enum member; `unpriced plan: 'pro'` versus `unpriced plan: <Plan.PRO: 'pro'>` tells you immediately which arrived, and therefore whether the bug is in the dispatch or at the boundary that should have converted it.
- How do you test that a match covers everything you believe it covers?Iterate the closed set itself rather than the branches: loop over every member of the enum, call the function on each, and assert nothing raises. Adding a member without adding a case then fails the suite at the moment the member is added — the same feedback a compiler would give, obtained from a test you had to write once.
- Would converting the input to an enum at the boundary make the catch-all unnecessary?No, and keeping both is the point. The boundary conversion catches bad *input*; the raising wildcard catches a valid member that this particular match forgot. They fail for different reasons and at different places, and the second one is what protects you the day someone extends the enum.
It is the difference between a checklist and a bouncer: a checklist can tell you a name is missing, but the interpreter is a bouncer trying names one at a time and shrugging when the queue runs out.
saying these in an interview costs you the question
- Expects the interpreter to warn about an unhandled enum member
- Believes `match` compiles to a jump table over known cases
- Says an annotation makes the runtime check the subject's type
- Claims class patterns are enumerable because classes are known
- Treats a silent fall-through as acceptable for a closed set
- Assumes a checker's proof still holds for untyped runtime input