Using python singledispatch for Clean Type-Based Dispatch
Learn how Python's functools.singledispatch enables clean type-based dispatch: registration patterns, inheritance and ABC behavior, performance characteristics, and practical examples.
Python's functools.singledispatch lets you define a generic function whose behavior depends on the runtime type of its first argument. Instead of writing a chain of isinstance checks, you register separate implementations for each type you care about, and the dispatcher selects the right one automatically. This keeps dispatch logic modular and avoids maintaining a growing conditional block.
How singledispatch Routes Calls by Type
The singledispatch decorator transforms a plain function into a generic function. The original function becomes the default implementation, used when no more specific registration matches the argument type. Each subsequent registration via @function.register(type) adds a specialized version. When you call the generic function, Python inspects the type of the first argument and looks up the most specific implementation.
For concrete classes, the dispatcher checks the type's MRO: exact type first, then base classes. If a class inherits from a registered base class, the base class implementation is used unless a more specific registration exists. Registrations against abstract base classes are also supported through virtual subclass checks, which is why collections.abc.Sequence can match concrete classes such as list.
Defining a Generic Function with a Default Implementation
Start by decorating a function with @singledispatch. The function body should handle the general case, often by raising TypeError or providing a sensible fallback. Here is a minimal example that formats a value as a string:
from functools import singledispatch @singledispatch def format_value(value): raise TypeError(f"Unsupported type: {type(value).__name__}")
This generic function currently has no specialized registrations, so calling format_value(42) raises TypeError. The default implementation is used only when no registered implementation applies.
Registering Implementations for Specific Types
To add behavior for a particular type, use the register method on the generic function. The explicit decorator syntax is readable:
@format_value.register(int) def _(value): return f"integer: {value}" @format_value.register(str) def _(value): return f"string: {value}" @format_value.register(list) def _(value): return f"list of {len(value)} items"
Now format_value(42) returns "integer: 42", format_value("hello") returns "string: hello", and format_value([1, 2, 3]) returns "list of 3 items". The underscore name is a convention for the implementation function; the function name is irrelevant because only the registration matters.
You can also register with a callable instead of a decorator, which is useful when you want to reuse an existing function:
def format_bool(value): return f"boolean: {value}" format_value.register(bool, format_bool)
Both approaches are equivalent. The decorator form is generally preferred for readability.
You can also let singledispatch infer the dispatch type from the first argument's annotation by using the bare register decorator:
@format_value.register def _(value: int): return f"integer: {value}"
This is equivalent to @format_value.register(int).
Handling Subclasses and ABCs in Dispatch
singledispatch uses the argument's type to select an implementation and respects concrete inheritance. If you register a base class, subclasses will use that implementation unless they have their own registration. For example:
class Animal: pass class Dog(Animal): pass @format_value.register(Animal) def _(value): return f"animal: {type(value).__name__}" print(format_value(Dog())) # "animal: Dog"
Because Dog is a subclass of Animal, the Animal implementation is used. If you later register Dog specifically, that registration takes precedence.
Abstract base classes from collections.abc work as well. You can register against Sequence, Mapping, or Iterable, and concrete classes that satisfy the ABC will dispatch correctly. This is particularly useful for handling broad categories without enumerating every concrete type, and a more specific registration such as list will still win when present.
Performance and Overhead of singledispatch
The dispatch lookup is not free, but it is not repeated in full on every call. singledispatch caches the resolved implementation for each concrete type. On the first call for a type, Python searches the registry and MRO; later calls use the cached implementation. Registering a new implementation clears the cache. The overhead is therefore small for typical application code, though a generic call still has an extra lookup layer compared with a direct function call. If profiling shows that a hot loop is affected, a manual dispatch table may be faster, but it loses automatic inheritance and ABC handling and requires more boilerplate.
Common Pitfalls and Limitations
One limitation is that singledispatch only dispatches on the first argument. If you need dispatch based on multiple arguments, consider nesting generic functions, using a dispatch table that considers all arguments, or using object-oriented polymorphism. For single-argument dispatch in a method, functools.singledispatchmethod provides the same pattern after self.
Another potential pitfall is registration without a dispatch type. Use an explicit type, as in @format_value.register(int), or annotate the first argument and use the bare @format_value.register form. A bare register on a function without an annotation has no type to register and raises an error.
Be careful with None. None is its own type, NoneType, so you must register type(None) explicitly if you want to handle it. Similarly, if you register a base class and a subclass, the most specific registration wins, but if two registrations are equally specific (for example, with multiple inheritance), the MRO order determines the result, which can be surprising.
When to Use singledispatch Instead of Alternatives
singledispatch is most valuable when you have a function that must behave differently for many unrelated types, and you want to keep those behaviors close to the types they handle. It is an alternative to a long if/elif chain using isinstance. The generic function approach makes it easier to add new types without modifying the original function, aligning with the open/closed principle.
However, if the number of types is small and unlikely to grow, a simple conditional may be more readable and faster. Also, if the dispatch logic depends on more than one argument, singledispatch is not the right tool. In that case, consider writing a small dispatch table or using object-oriented polymorphism where each class implements a common method.
When you need to extend behavior for types you do not control, singledispatch is particularly useful because you can register new implementations from anywhere in the codebase, as long as the generic function is importable. This makes it a good fit for plugin architectures or library extensions.
Compatibility and Python Versions
functools.singledispatch has been part of the standard library since Python 3.4 and is available in every Python 3 release since then, so modern projects do not need a backport. If you support Python 2, you would need a third-party backport. The core API has been stable, and Python 3.8 added functools.singledispatchmethod for the same pattern on methods.
Practical Example: A Simple Event Handler
To see how singledispatch works in a realistic scenario, consider an event processing system where different event types require different handling logic. Instead of a large if/elif block, you can define a generic handle_event function and register handlers for each event class.
from functools import singledispatch from dataclasses import dataclass @dataclass class UserCreated: user_id: int email: str @dataclass class OrderPlaced: order_id: int amount: float @singledispatch def handle_event(event): raise TypeError(f"No handler for {type(event).__name__}") @handle_event.register(UserCreated) def _(event): print(f"Send welcome email to {event.email}") @handle_event.register(OrderPlaced) def _(event): print(f"Process payment of ${event.amount:.2f}")
This pattern scales cleanly: adding a new event type only requires a new dataclass and a new registration. The dispatcher remains unchanged, and each handler is isolated. This is a common use case in event-driven applications where the set of event types grows over time.