When is functools.singledispatch a better fit than an isinstance ladder or a method on the class?
answer
- Three shapes solve the same problem
- Who owns the types decides a lot
- Open extension versus visible local branching
- Registrations only exist if imported
- Dispatch is first positional argument only
basics
~20 sPrefer it when the operation does not belong on the types, or those types are not yours to edit, so new cases arrive as registrations. Keep a method when you own the hierarchy, a ladder when branching is small and closed.
solid answer
~50 sThree shapes solve the same problem. A **method on the class** is right when you own every type and the behaviour genuinely belongs to them — that is ordinary polymorphism and the most readable option. An **isinstance ladder** is right when the branching is small, local and closed; its ordering is visible in one place, which is a real virtue. `functools.singledispatch` earns its keep when the operation is *about* the types but does not belong *on* them — serialization, rendering, normalization — or when the types come from elsewhere and you cannot add methods to them. It resolves by class relationship rather than branch order, so a plugin can register a handler without editing the original function. The costs are real: first positional argument only, no dispatch on value or shape, dispatch logic scattered across modules, and registrations that only exist if their module is imported.
code
python · 22 linesfrom datetime import date
from decimal import Decimal
from functools import singledispatch
@singledispatch
def as_json(value):
raise TypeError(f"no encoder for {type(value).__name__}")
@as_json.register
def _(value: date):
return value.isoformat()
@as_json.register
def _(value: Decimal):
return f"{value:f}"
print(as_json(date(2026, 9, 4)))
print(as_json(Decimal("0.10")))
try:
as_json(object())
except TypeError as exc:
print(exc)go deeper
Know the three options exist — a method on the class, a chain of isinstance tests, and a dispatch registry — and that dispatch keys only on the type of the first positional argument.
Explain the concrete trade-off: open extension and order-independent resolution against a decision that is now spread across modules, and be able to point at types you cannot add methods to.
Bring the operational angle: registration is an import-time side effect, a missing import silently means the fallback, and the fallback should raise rather than guess in a data path.
Own the standard for the codebase — where registrations may live, whether handler modules are imported eagerly at startup and what that costs, and when a plugin-extensible dispatch point is worth the loss of a single readable branching site.
This is a design question, and the honest answer names all three options and the conditions that select between them. ## Method on the class If you own the type hierarchy and the operation belongs to the objects, write a method. Nothing beats it for readability, tooling and discoverability: the behaviour lives next to the state it uses, type checkers and IDEs follow it, and a subclass overrides it in the obvious way. Reaching for a dispatch table over your own classes is usually a sign the behaviour was put in the wrong place. ## The isinstance ladder A short chain of `isinstance` tests inside one function is not a code smell by default. Its ordering is explicit and local: a reader sees every case and the precedence between them in one screen. It is the right answer when the set of cases is closed, small and unlikely to be extended by other packages. It degrades on three axes: it grows, it is *closed* (a new type means editing a function you may not own), and its correctness depends on branch order — a broad test placed early silently shadows the narrower ones below it, and nothing tells you. ## functools.singledispatch `singledispatch` is the open-extension version of that ladder. The operation lives outside the types, and each type's implementation is registered against it, potentially from another module. Choose it when: * **The types are not yours.** You cannot add a method to a class from the standard library or a third-party package, but you can register an implementation for it. * **The operation is cross-cutting and not intrinsic.** Encoding to a wire format, rendering, unit normalization, importing. Bolting all of those onto the domain classes as methods bloats them with concerns that belong to one adapter. * **New cases arrive from outside.** A plugin package registering support for its own type without a patch to the core is the shape `singledispatch` is for. * **Resolution should follow class relationships.** Registration order does not matter, and the most derived registered class wins — you cannot break dispatch by inserting a branch in the wrong place, which is a genuine ordering-fragility win over the ladder. ## What it costs, and the operational traps * **One argument, and only its type.** Dispatch keys on the first positional argument. No multiple dispatch, no dispatch on value, shape, or a keyword argument. If your branching is really "if the list is empty" or "if the encoding is utf-8", this is the wrong tool and a ladder is honest. * **The decision is spread out.** With a ladder, one function tells you everything. With `singledispatch`, the answer to "what runs for this type?" is assembled from registrations across packages. Budget for that: keep registrations in predictable modules, and know that the generic function's `dispatch()` attribute answers the question directly when debugging. * **Registration is an import-time side effect.** An implementation exists only if the module containing it has been imported. This is the failure that bites in production: the handler is present in the source, the tests import it, and the service does not — so the fallback runs. It also pushes teams into importing every handler module at startup to guarantee registration, which is precisely how an importer ends up with a 45-second cold start it cannot lazily avoid without risking an unregistered type. Decide deliberately: eager imports in one registry module, or lazy imports with an explicit check that the expected types resolve. * **A silent fallback hides gaps.** As with the ladder's missing `else`, the base implementation catches everything unmatched. Make it raise unless a generic behaviour is genuinely correct. * **Static analysis sees less.** Type checkers understand a method override far better than a registry of implementations; you get less help from tooling on argument and return types. * **Per-call cost is small but not zero** — a cached type lookup — which matters only in very hot loops, and the cache is invalidated whenever something new is registered. ## How to answer it in an interview Say what selects each option rather than declaring a winner: own the types and the behaviour belongs to them, write a method; small closed local branching, write the ladder and keep it visible; the operation is external to types you may not own and the case set is open, use `singledispatch` — and then name the price you accept, which is scattered dispatch and import-time registration.
- What goes wrong when a registration lives in a module the running service never imports?The implementation simply does not exist at runtime, so that type falls through to the base implementation. Tests that import the module pass while production takes the fallback. Fix it by importing handler modules from one explicit registry module, or by asserting at startup that the expected types resolve.
- When would you keep an isinstance chain instead of converting it to functools.singledispatch?When the branching is small, local and closed, or when it keys on something other than the first argument's type — a value, a length, an encoding, or a combination of two arguments. Dispatch cannot express those, and forcing them through it produces a registry plus the ladder you were trying to remove.
- How does it change the readability of a codebase for a new maintainer?It trades one visible branching site for a set of registrations spread across modules. That is worth it when the case set is open, and a net loss when it is not. Mitigate it by keeping registrations in predictable locations and by using the generic function's `dispatch()` attribute when tracing behaviour.
saying these in an interview costs you the question
- Calls every isinstance chain a code smell
- Claims it replaces ordinary polymorphic methods
- Thinks it can dispatch on two arguments
- Ignores that registration needs the module imported
- Expects type checkers to follow registered implementations
- Uses it to branch on values rather than types