skip to content

How do you handle an unknown key in a Python dict dispatch table without masking errors raised by the handler?

level: middleimportance: should knowfreq 46%

answer

  1. The key is missing, or the handler failed
  2. Where the try block actually ends
  3. Which exception a handler raises most often
  4. Look up first, call on a separate line
  5. `dict.get` with a default handler, never defaultdict

basics

~20 s

Separate the lookup from the call: handlers.get(key, fallback) or a get returning None followed by an explicit raise, then invoke the handler on its own line. Wrapping handlers[key]() in try/except KeyError also catches a KeyError raised inside the handler.

solid answer

~50 s

The safe shape is lookup first, call second. `handler = handlers.get(kind)` returns `None` for an unknown key, so you can raise a domain error naming the key, or use `handlers.get(kind, fallback)` to supply a default handler with the same signature. What you must not do is write `try: handlers[kind](record) except KeyError:` — the `try` block covers the call as well as the subscript, so a `KeyError` raised deep inside the handler (a missing column in a row, say) is caught and silently routed to the unknown-key path. In an ETL export that turns a real data bug into rows quietly skipped. If you prefer the EAFP form, put only the subscript inside the `try` and call outside it. Avoid `collections.defaultdict` here: its factory takes no arguments so it cannot see the key, and a miss inserts a new entry, growing the table with junk.

code

python · 22 lines
python
def export_rows(record):
    return record["id"]          # a KeyError here is a real data bug

handlers = {"rows": export_rows}

def dispatch(kind, record):
    handler = handlers.get(kind)
    if handler is None:
        raise ValueError(f"no export handler for {kind!r}")
    return handler(record)

print(dispatch("rows", {"id": 7}))

try:
    dispatch("blobs", {})
except ValueError as exc:
    print("unknown kind:", exc)

try:
    dispatch("rows", {})
except KeyError as exc:
    print("real bug, not swallowed:", exc)

go deeper

for a junior

Know that dict.get returns None (or a default you supply) instead of raising when a key is absent, and that a dispatch table needs some answer for a key nobody registered. Be ready to write the lookup and the call as two separate steps.

for a middle

Explain why try: handlers[kind](record) is too wide a net: the call is inside the block, so a KeyError from inside the handler is caught as if the handler were missing. Show the narrow EAFP form and why collections.defaultdict mutates on a miss.

for a senior

Show the production consequence — an export that reports success while quietly rerouting real data errors to the unknown path — and argue for failing loudly with the offending key named, plus a skip count that is a metric rather than a log line nobody reads.

for a principal

Decide the policy: is the key set closed, so an unrecognised kind halts the job, or open, so skipping is contractual? Push the check to load time where an enum or config makes the key set knowable, so dispatch misses become startup failures rather than partial runs.

## Three ways to look up, and only two of them are safe A dispatch table has to answer one more question than the mapping itself does: what happens when the key has no handler? Python offers three shapes, and the difference between them is precisely where the boundary of the error handling falls. **Subscript with a default handler.** `handlers.get(kind, fallback)` returns `fallback` when the key is absent. It is the most compact form and it is correct as long as `fallback` is a callable with the same signature as every real handler, so the call site can invoke whatever came back without knowing which it got. This is where a `report_unknown(record)` or a no-op handler goes. **Lookup, test, call.** ```python handler = handlers.get(kind) if handler is None: raise ValueError(f"no export handler for {kind!r}") return handler(record) ``` This is the shape to reach for when an unknown key is a defect rather than a case. It fails loudly, and crucially the error message names the offending key — the single most useful thing a dispatch failure can tell you, because the caller usually has no idea which of a thousand records carried it. It also keeps the call strictly outside any error handling. **Subscript inside a try block.** This is the trap: ```python try: return handlers[kind](record) except KeyError: return handle_unknown(record) ``` The `try` block covers *both* the subscript and the call, and `KeyError` is one of the most commonly raised exceptions in Python — every dict access inside the handler can raise one. So a handler that does `record["customer_id"]` on a row missing that column raises `KeyError`, the `except` clause catches it, and the record is quietly reclassified as "unknown type". In an ETL export to a warehouse that is a silent partial failure: the job reports success, some rows land in the reject path for the wrong reason, and the mismatch is only discovered downstream when totals do not reconcile. The same shape hides `KeyError` from environment lookups, JSON payload access, and cache reads inside the handler. If you want the EAFP style, narrow the block to the lookup alone: ```python try: handler = handlers[kind] except KeyError: raise ValueError(f"no export handler for {kind!r}") from None return handler(record) ``` Now the call happens outside, and anything the handler raises propagates as itself. `from None` suppresses the chained `KeyError` context when the message would be noise; keep the chaining when the original is informative. ## Why collections.defaultdict is the wrong tool here `collections.defaultdict` is a tempting one-liner and a poor fit for dispatch, for two reasons. Its `default_factory` is called with no arguments, so the default handler cannot be chosen based on which key was missing, and it cannot even report the key it was standing in for. And a miss **inserts**: every unknown key permanently adds an entry, so a table fed by untrusted input grows without bound and its `keys()` stops describing what the program actually supports. `dict.setdefault` has the second problem too, and additionally evaluates its default argument on every call whether or not it is needed. Plain `dict.get` with a default does neither: it mutates nothing. ## Designing the fallback itself Whether the fallback raises or absorbs is a product decision, not a style one. Raising is right when the key set is closed and an unknown value means upstream sent something the exporter does not understand — better to stop than to write a partial, silently incomplete result. Absorbing is right when the key set is open by design and skipping is a legitimate outcome; then the fallback should still record what it skipped, with the key and enough identifying detail to find the record later, so the count of skips is a metric rather than a mystery. A third option is worth naming: validate the key set at startup rather than at dispatch. If the keys come from an `enum.Enum` or from a config file, you can check on load that every enum member has a handler, and turn a runtime dispatch miss into an import-time failure. That is the strongest version of the fallback question — the case that never reaches production. ## The shape to remember Look up, decide, then call. Everything that can go wrong with dispatch fallbacks comes from letting the call happen inside the construct that was meant to protect the lookup.

  • Why is collections.defaultdict a poor choice for the missing-handler case?
    Two reasons. Its factory is called with no arguments, so the default handler cannot know or report which key was missing. And a miss inserts the new entry, so an unknown key permanently joins the table — feed it untrusted input and it grows without bound while `keys()` stops describing what the program supports. `dict.get` with a default returns the fallback and mutates nothing.
  • When should an unknown dispatch key raise rather than fall through to a no-op handler?
    Raise when the key set is closed and an unknown value means the upstream sent something this code does not understand — continuing produces a silently incomplete result. Absorb only when skipping is a legitimate outcome, and even then the fallback should record the key and enough detail to find the record later, so skips are a countable metric. Either way the error or log line must name the key.
  • How would you catch a missing handler before the program ever dispatches?
    Validate the key set at load time. If the keys come from an `enum.Enum` or a config file, assert at import that every member has an entry in the table, so a gap fails at startup instead of on one unlucky record hours into a run. A static type checker can also enforce exhaustiveness when the key type is a literal or enum, turning the check into a build-time one.

saying these in an interview costs you the question

  • Wraps the lookup and the handler call in one try/except KeyError
  • Uses defaultdict so unknown keys silently insert entries
  • Raises a bare KeyError without naming the offending key
  • Silently returns None for an unknown key and continues
  • Gives the fallback handler a different signature from the real ones
  • Says a KeyError inside a handler cannot happen in practice

context