Skip to content
Python Basics: Choosing Implementations with singledispatch

Python Basics: Choosing Implementations with singledispatch

October 21, 2026

A formatting function may need one implementation for text and another for numbers. functools.singledispatch provides a shared function name with implementations registered for different types. It chooses using the runtime type of the first argument.

This is separate from functools caching: no result is saved for later reuse. The examples focus on a default implementation, inheritance, and collection types. Each file defines its own function and registrations, so it can run independently.

Prepare a scratch directory

These three standalone examples were tested on Linux with Python 3.12.14. Use Bash, Python 3.12 or newer, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Keep assertions enabled by running without Python’s -O option.

Create a fresh directory and keep using the same terminal:

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

Save each named source file here as UTF-8. Avoid naming a source file functools.py. The examples format invented values in memory and do not modify their input collections.

1. Register text and integer handlers

Save as dispatch_display_value.py:

from functools import singledispatch

@singledispatch
def describe(value):
    return f"other: {type(value).__name__}"

@describe.register(str)
def describe_text(value):
    return f"text: {value.upper()}"

@describe.register(int)
def describe_integer(value):
    return f"integer: {value}"

assert describe("hi") == "text: HI"
assert describe(7) == "integer: 7"
assert describe(None) == "other: NoneType"
print(describe("hi"))
print(describe(7))
print(describe(None))
print("Empty text:", repr(describe("")))

Run it:

python3 dispatch_display_value.py

Output:

text: HI
integer: 7
other: NoneType
Empty text: 'text: '

The decorated function is the fallback. register associates an implementation with a type, while ordinary callers keep using describe. Distinct implementation names make the individual functions easy to inspect and test.

The empty string still takes the text path: dispatch depends on its type, not whether it is truthy. None takes the fallback because no more-specific implementation was registered for it. This makes the default behavior an intentional part of the interface.

2. Account for subclasses such as bool

Save as dispatch_boolean_value.py:

from functools import singledispatch

@singledispatch
def label(value):
    return "other"

@label.register(int)
def label_integer(value):
    return f"integer: {value}"

print("Before bool handler:", label(True))
assert label.dispatch(bool) is label_integer

@label.register(bool)
def label_boolean(value):
    return "yes" if value else "no"

assert label.dispatch(bool) is label_boolean
assert label(0) == "integer: 0"
print("After bool handler:", label(True))
print("False:", label(False))
print("Zero:", label(0))

Run it:

python3 dispatch_boolean_value.py

Output:

Before bool handler: integer: True
After bool handler: yes
False: no
Zero: integer: 0

bool is a subclass of int. Before the bool registration, the integer handler is the closest match. The explicit bool handler takes precedence once registered, while zero still follows the integer path.

dispatch lets the test inspect which implementation would be selected without calling it. In application code, establish registrations during setup rather than surprising callers with changing behavior midway through an operation. A type-specific function should preserve the public function’s overall purpose and return-value expectations.

3. Distinguish container type from item type

Save as dispatch_collection_summary.py:

from functools import singledispatch

@singledispatch
def summarize(value, *, prefix="Items"):
    return f"{prefix}: unsupported"

@summarize.register(list)
def summarize_list(value, *, prefix="Items"):
    return f"{prefix}: list of {len(value)}"

@summarize.register(tuple)
def summarize_tuple(value, *, prefix="Items"):
    return f"{prefix}: tuple of {len(value)}"

assert summarize([1, 2]) == summarize(["a", "b"])
assert summarize([]) == "Items: list of 0"
print(summarize([1, 2]))
print(summarize(["a", "b"]))
print(summarize([], prefix="Empty"))
print(summarize((1, 2), prefix="Pair"))
print(summarize({1, 2}))

Run it:

python3 dispatch_collection_summary.py

Output:

Items: list of 2
Items: list of 2
Empty: list of 0
Pair: tuple of 2
Items: unsupported

Both lists take the same implementation, regardless of their elements. Register list, not list[int], as the runtime dispatch type. If one handler needs to validate its elements, that is a separate check inside the function.

The prefix argument changes the label but does not select a handler. Only the first argument drives singledispatch, and it is passed positionally in these examples. Keep the other parameters compatible across implementations so the shared call pattern remains reliable.

For a small condition based on values rather than types, an ordinary if statement is often clearer. singledispatch becomes useful when independently defined type handlers help organize a growing operation without requiring one long chain of isinstance checks.

Clean up

In the same terminal, remove only these source files, return to the original directory, and remove the now-empty scratch directory:

rm -- dispatch_display_value.py dispatch_boolean_value.py dispatch_collection_summary.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

If rmdir reports that anything remains, inspect the directory rather than deleting unknown files.

References

Acknowledgements

This post was created with assistance from AI.

Last updated on