When does a dict of functions beat an if/elif chain for dispatch in Python?
answer
- replace branching with a lookup
- handlers become values, not statements
- one new entry per new case
- a missing key should raise, not pass
- constant time and independently testable
basics
~20 sA dict mapping keys to function objects makes branch selection one lookup, keeps handlers independently testable, and turns an unknown key into a loud KeyError instead of a silent fall-through. Keep if/elif when conditions are not equality on one key.
solid answer
~50 sThe table is a dict whose keys are what you would have branched on and whose values are the function objects themselves — no call parentheses. The strongest argument is error behaviour: an `if/elif` chain with no `else` falls off the end and returns `None`, so a search-index rebuilder that gained a new mode upstream can "succeed" while writing a truncated index; a dict raises `KeyError` on the unknown mode, which you translate into a domain error naming the key. Growth also stops touching existing code — a new mode is a function plus an entry — and each handler is a named, importable unit, so a 340-case regression pack parametrised over the table cannot drift from it. Constant-time lookup is the least important benefit. Keep the chain when conditions are ranges or compound predicates, when there are two or three stable branches, or when handlers cannot share a signature.
code
python · 16 linesdef rebuild_full(job):
return f"full:{job}"
def rebuild_delta(job):
return f"delta:{job}"
HANDLERS = {"full": rebuild_full, "delta": rebuild_delta}
def run(mode, job):
try:
handler = HANDLERS[mode]
except KeyError:
raise ValueError(f"unknown rebuild mode {mode!r}; expected {sorted(HANDLERS)}") from None
return handler(job)
print(run("delta", 340))go deeper
Recall that a dict can hold functions as values and that you store the name without parentheses, then call the looked-up value. Practise turning a three-branch conditional into a table.
Explain the mechanics: keys map to function objects, lookup replaces the branch, an unknown key raises KeyError, and handlers must share a signature to be interchangeable.
Argue from failure modes. Show how a missing branch fails silently in a conditional and loudly in a table, and how parametrising tests over the table keeps cases and handlers in step.
Own where indirection belongs. Decide when behaviour should be data the system looks up versus code a reader can see in one place, and what that choice costs in review, tracing and onboarding.
### The pattern A dispatch table is a dict whose **keys are the thing you were about to branch on** and whose **values are function objects**. Selecting behaviour becomes a lookup and a call: ```python HANDLERS = {"full": rebuild_full, "delta": rebuild_delta} HANDLERS[mode](job) ``` It is possible only because functions are first-class values — the dict stores the objects themselves, with no call parentheses. Writing `{"full": rebuild_full()}` calls the handler at import time and stores its return value, which is the classic way this pattern is broken on first attempt. ### What the table actually buys you **A missing case becomes loud.** This is the strongest argument and the one candidates most often miss. An `if/elif` chain with no final `else` falls off the end and returns `None`. Consider a search index rebuilder that dispatches on a mode string: a mode was added upstream, the chain was never extended, and the run "succeeded" while writing only the documents the earlier branches handled — a silent truncation that no test caught, because the process exited zero. A dict lookup raises `KeyError` at the moment the unknown mode appears, and translating it into a domain error with the key in the message turns an invisible data-loss bug into a one-line failure. **Growth stops touching existing code.** Adding a mode is a new function plus a dict entry. The conditional is never re-read, re-indented or accidentally re-ordered, and two people adding two modes touch different lines. A conditional that has grown to a dozen branches is also a single function that no reviewer reads carefully any more. **Handlers become units.** Each one is a named, importable, individually testable function with its own docstring. Where a regression pack has to exercise every branch — say a 340-case pack over the rebuild modes — parametrising over `HANDLERS` guarantees the tests and the implementation cannot drift apart: a new handler with no case is visible, and a case with no handler fails loudly. **Lookup is constant time.** True, and almost always the least important benefit. At three branches it is noise; be careful not to lead with it, because an interviewer will read that as cargo-culting. ### Handling the unknown key properly Three shapes, in descending order of safety: ```python handler = HANDLERS[mode] # KeyError names the key handler = HANDLERS.get(mode, _unsupported) # default that itself raises handler = HANDLERS.get(mode) # returns None — the trap ``` The third produces `TypeError: 'NoneType' object is not callable` at the call site, which at least fails; the genuinely dangerous variant is `if handler: handler(job)`, which skips the work and reports success. Catching the `KeyError` and re-raising a domain error that names the mode and lists the valid ones is what you want in production code. ### When `if/elif` is still right Be explicit about this; a candidate who claims every conditional should become a dict has not thought it through. - **The conditions are not equality on one key.** Numeric ranges, compound predicates, checks against several variables — a dict cannot express them, and forcing it produces a lookup of a lambda that re-tests the condition anyway. - **There are two or three stable branches.** The table adds indirection and buys nothing. - **Branch bodies need different arguments.** Table handlers must share a signature; when they do not, you end up passing a bag of arguments most handlers ignore, or a context object invented only to satisfy the table. - **Order matters.** A chain is evaluated top to bottom and the first match wins; a dict has no precedence. The real cost is **locality**. A reader of the chain sees every possibility in one screen; a reader of the table sees a lookup and must go find the handlers. Static call-graph tools and "find usages" degrade the same way. That trade is worth it when the number of branches is open-ended, and not worth it when it is fixed and small. ### Neighbouring tools `functools.singledispatch` does the same job when the branch is on the **type** of an argument rather than a value key: implementations register per type and the lookup follows the class hierarchy, so a subclass inherits a handler. The `match` statement (Python 3.10, PEP 634) covers structural dispatch — matching the *shape* of a value, not one key. A dict of functions remains the right answer for the common case: a finite, growing set of names mapped to behaviours. ### How to answer Lead with error behaviour and change cost, mention testability, mention constant-time lookup last, and finish by naming a case where you would keep the conditional. That shape shows judgement rather than a preference for a pattern.
- How do you handle an unknown key without silently returning None?Index the dict and translate the `KeyError` into a domain error that names the key and lists the valid ones, or use `HANDLERS.get(key, _unsupported)` where the default handler itself raises. The trap is `HANDLERS.get(key)` followed by a call, which produces an opaque `TypeError: 'NoneType' object is not callable`, and worse is `if handler: handler(job)`, which skips the work and reports success.
- When is `functools.singledispatch` a better fit than a hand-rolled dict?When the branch is on the *type* of an argument rather than on a value key. `singledispatch` registers an implementation per type and resolves through the class hierarchy, so a subclass inherits a handler and a type defined elsewhere can register its own later. A dict keyed by a mode string has no inheritance-aware lookup and no registration hook for code you do not own.
- What do you lose by moving branches out of a conditional into a table?Locality, mostly. A reader of the chain sees every possibility in one screen; a reader of the table sees a lookup and has to go find the handlers, and static call-graph tools and "find usages" both degrade. Handlers must also agree on a signature, which often forces a context object invented only to satisfy the table. For three stable branches the conditional is simply clearer.
saying these in an interview costs you the question
- Leads with constant-time lookup as the main benefit at three branches
- Stores handler() results in the dict instead of the function objects
- Uses HANDLERS.get(key) and calls the result without checking
- Forgets the else branch and lets an unknown mode return None
- Insists every conditional should become a dispatch dict
- Cannot name a case where if/elif is still the right choice