Python Single Dispatch: Type-Based Function Overloading
python single dispatch: Learn how functools.singledispatch enables Python single dispatch, replacing isinstance chains with type-based function overloading.
Python's functools.singledispatch decorator turns a plain function into a generic function that dispatches on the type of its first argument. Python single dispatch is the standard library's answer to function overloading: instead of writing isinstance chains, you register type-specific implementations and let the runtime pick the right one.
The Problem: Type-Checking Chains
Consider a function that formats values for display:
def format_value(value): if isinstance(value, int): return f"integer: {value}" elif isinstance(value, float): return f"float: {value:.2f}" elif isinstance(value, str): return f"string: {value!r}" elif isinstance(value, list): return f"list of {len(value)} items" else: return f"unknown: {type(value).__name__}"
This works, but every new type means editing the function and extending the chain. The dispatch logic is entangled with the formatting logic, and the order of checks matters: if you add a bool check after int, True and False will be treated as integers because bool subclasses int. Each addition makes the function harder to read and test.
Basic singledispatch Syntax
The same logic with singledispatch separates dispatch from implementation:
from functools import singledispatch @singledispatch def format_value(value): return f"unknown: {type(value).__name__}" @format_value.register def _(value: int): return f"integer: {value}" @format_value.register def _(value: float): return f"float: {value:.2f}" @format_value.register def _(value: str): return f"string: {value!r}" @format_value.register def _(value: list): return f"list of {len(value)} items"
The base function decorated with @singledispatch becomes the generic entry point. Its body serves as the fallback for unregistered types. Each registered function is keyed by the type annotation on its first parameter. Calling format_value(42) resolves to the int implementation; calling format_value([1, 2]) resolves to the list implementation.
How Dispatch Resolves Types
When you call the generic function, singledispatch looks up the argument's concrete type in its registry. If the type isn't registered directly, it walks the type's MRO (method resolution order) looking for a registered ancestor. This is why registering for object catches everything, and why registering for a base class like collections.abc.Sequence catches list, tuple, and str — unless a more specific registration for that type exists, in which case the specific one wins.
The resolution happens once per concrete type and is cached. Subsequent calls with the same type use the cached implementation directly.
Registering with Explicit Types
Type annotations are optional. The register method accepts an explicit type:
@format_value.register(dict) def format_dict(value): return f"dict with {len(value)} keys"
Use this form when the function name matters for readability, when the annotation would be ambiguous, or when you want to register a type that isn't the first parameter's annotation. The two forms are interchangeable; the registry keys on the type, not on the function name.
Registering for Abstract Base Classes
singledispatch works with ABCs, which lets you handle a family of related types with one implementation:
from collections.abc import Mapping @format_value.register(Mapping) def _(value: Mapping): return f"mapping with {len(value)} keys"
Because dict and OrderedDict are registered as virtual subclasses of Mapping, both resolve to this implementation. The MRO walk handles ABC registration through __subclasshook__, so the dispatch works even for types that don't inherit from the ABC directly.
A Practical Example: Recursive Serialization
Here's a realistic use case: a serializer that converts Python values to JSON-compatible structures.
from functools import singledispatch from datetime import datetime from pathlib import Path @singledispatch def to_json_value(value): raise TypeError(f"cannot serialize {type(value).__name__}") @to_json_value.register def _(value: int): return value @to_json_value.register def _(value: float): return value @to_json_value.register def _(value: str): return value @to_json_value.register def _(value: bool): return value @to_json_value.register def _(value: datetime): return value.isoformat() @to_json_value.register def _(value: Path): return str(value) @to_json_value.register def _(value: list): return [to_json_value(item) for item in value] @to_json_value.register def _(value: dict): return {key: to_json_value(item) for key, item in value.items()}
The base function raises TypeError for unsupported types, which is the right behavior for a serializer: fail loudly rather than silently producing wrong output. Nested structures work because the list and dict implementations call to_json_value recursively, and dispatch happens per element.
Performance and Dispatch Overhead
singledispatch caches the resolved implementation per concrete type. The first call for a given type performs the MRO lookup; subsequent calls use the cached result. The per-call overhead is a dictionary lookup plus a function call, which is comparable to calling the function through a dict of handlers.
For typical application code, this overhead is negligible. If you're dispatching millions of times per second in a hot loop, consider whether the dispatch can be hoisted out of the loop or replaced with a plain dictionary keyed by type. The tradeoff is that a manual dictionary loses the MRO-based fallback behavior.
When Not to Use singledispatch
singledispatch only considers the first argument. If you need dispatch on multiple arguments, it won't help. For methods, functools.singledispatchmethod provides the same mechanism for the self argument, but it still dispatches on a single argument.
The MRO-based resolution can also surprise you. If you register handlers for both Sequence and str, a str argument resolves to the str handler because str appears earlier in its own MRO. Understanding the resolution order is essential when registering for both concrete types and ABCs.
| Approach | Dispatch basis | Extensibility | Runtime cost |
|---|---|---|---|
isinstance chain | Manual checks | Edit function | O(n) checks |
singledispatch | First argument type | Register new handler | Cached lookup |
| Method dispatch | self type | Subclass the class | Direct call |
Compatibility and Union Types
singledispatch was added in Python 3.4. The explicit-type form of register works from 3.4 onward. The annotation-based form requires the annotation to be a concrete type at decoration time, which is the default behavior in standard Python.
Union types are not supported. @format_value.register with int | str will not dispatch correctly. Register each type separately, or register a common base class that both types share.
Debugging Dispatch Behavior
If a registered function never gets called, inspect format_value.registry — it's a plain dict mapping types to functions. Check whether the argument's type resolves to a different registered ancestor earlier in the MRO, and verify that the annotation on the first parameter is present and is a real type, not a string.