A function sometimes needs to perform the same conceptual operation for several unrelated Python types.

The straightforward solution is usually an if chain:

def format_value(value):
    if isinstance(value, str):
        return value
    if isinstance(value, int):
        return str(value)
    if isinstance(value, dict):
        return ", ".join(f"{key}={item}" for key, item in value.items())
    raise TypeError(f"unsupported type: {type(value).__name__}")

This is perfectly reasonable when the set of supported types is small and unlikely to grow.

The design becomes harder to maintain when one operation accumulates many type-specific branches. Every new type edits the same function, unrelated implementations become interleaved, and extension code must know where to insert another condition.

Python’s functools.singledispatch offers another model: define one generic operation, then register separate implementations for the runtime type of its first argument.

The mental model is:

call generic function
       |
       v
inspect first argument's runtime type
       |
       v
choose the most specific registered implementation
       |
       v
run that implementation

Single dispatch is useful when the operation is conceptually one thing, but its implementation naturally depends on the type of one value.

Start with the smallest useful generic function

A single-dispatch function begins with a default implementation.

from functools import singledispatch


@singledispatch
def serialize(value):
    raise TypeError(f"unsupported type: {type(value).__name__}")

The decorated function is now a generic function. The default implementation is associated with object, so it is the fallback when no more specific registration matches.

That fallback is a good place to define unsupported behavior explicitly. Returning a vague default such as None can hide missing registrations.

Now register implementations for supported types:

@serialize.register
def _(value: str):
    return value


@serialize.register
def _(value: int):
    return str(value)

Calls still use one public function:

serialize("ready")  # "ready"
serialize(42)       # "42"

The caller does not choose an implementation. Dispatch happens inside the generic function based on the runtime type of value.

Dispatch uses only the first argument

The word single in single dispatch matters.

Consider:

@singledispatch
def render(value, compact=False):
    ...

Dispatch depends on the type of value, not compact.

These two calls therefore select the same registered implementation:

render(42, compact=False)
render(42, compact=True)

The second argument can still change what that implementation does, but it does not participate in implementation selection.

If behavior depends on a combination such as (source_type, destination_type), singledispatch is not a direct multiple-dispatch mechanism. An explicit lookup table, normal condition logic, or a different design may be clearer.

Registration can use annotations or explicit types

When the registered function annotates its first argument, register() can infer the dispatch type:

@serialize.register
def _(value: bytes):
    return value.hex()

You can also provide the type explicitly:

@serialize.register(float)
def serialize_float(value):
    return format(value, ".2f")

The explicit form is useful when annotations are undesirable or when the registration target is easier to understand separately from the function signature.

In both cases, the registration controls runtime dispatch. Type annotations do not make ordinary Python function calls statically dispatched.

Subclasses use the nearest applicable registration

Single dispatch is more useful than a simple exact-type dictionary because it respects class relationships.

Suppose int is registered:

@serialize.register
def _(value: int):
    return str(value)

A subclass of int can use that implementation when no more specific registration exists.

This also creates an edge case with bool, because bool is a subclass of int:

serialize(True)

If only int is registered, the integer implementation handles the Boolean value.

If Boolean serialization needs different semantics, register it explicitly:

@serialize.register
def _(value: bool):
    return "true" if value else "false"

Now bool is the more specific match for Boolean arguments.

This is an important design rule: register the semantic categories your operation actually distinguishes, not merely the broadest classes that happen to accept the values.

Abstract base classes can define useful categories

Registrations are not limited to concrete classes.

For example, a serializer may care about mapping behavior rather than whether the value is specifically a dict:

from collections.abc import Mapping


@serialize.register
def _(value: Mapping):
    return ";".join(
        f"{key}={item}"
        for key, item in value.items()
    )

Now compatible mapping types can share one implementation.

serialize({"theme": "dark", "page": 2})

A subclass of dict, for example, can use the Mapping registration without needing its own handler.

This is often better than registering many concrete container classes individually. The operation states what capability it needs instead of naming every implementation type.

Do not choose an abstract base class merely because it makes more objects match. A broad registration should represent a real semantic promise that the implementation can satisfy.

More specific registrations override broader ones

Imagine these registrations:

object
  |
  +-- Mapping
  |     |
  |     +-- CustomMapping
  |
  +-- int
        |
        +-- bool

If Mapping is registered and a concrete mapping subclass has no own registration, the mapping implementation can handle it.

If that concrete subclass later gets its own registration, that more specific implementation wins for instances of the subclass.

Likewise, a bool registration is preferred over an int registration for Boolean values.

This makes generic functions extensible without requiring one central condition chain to know every concrete subtype in advance.

Inspect dispatch when debugging behavior

A generic function exposes dispatch() for checking which implementation would be selected for a type.

implementation = serialize.dispatch(int)

This is useful in tests and debugging because it lets you inspect selection without constructing a value or executing the implementation.

The generic function also exposes a read-only registry:

for registered_type in serialize.registry:
    print(registered_type)

The registry includes the default object implementation along with explicitly registered types.

These APIs are useful for diagnostics, but application logic should rarely need to branch on the registry itself. If callers routinely inspect registrations before calling the function, the abstraction may be leaking.

Keep the public operation separate from implementation names

Examples commonly use _ for registered implementation names:

@serialize.register
def _(value: str):
    return value

That works because the registered functions do not need to be called directly by those local names.

For larger modules, descriptive names can improve tracebacks and debugging:

@serialize.register
def serialize_text(value: str):
    return value


@serialize.register
def serialize_integer(value: int):
    return str(value)

The public API remains serialize(...), while implementation names communicate their roles to maintainers.

Choose naming based on the size and debugging needs of the module rather than copying _ mechanically.

Separate dispatch from business rules

Single dispatch solves implementation selection by type. It does not automatically make the selected implementation well designed.

Keep the distinction clear:

dispatch question:
Which implementation handles this runtime type?

business question:
What result should this particular value produce?

A registered function can contain normal business logic, but do not turn every value-level branch into another type hierarchy merely to use single dispatch.

Single dispatch can reduce extension conflicts

A long type switch tends to concentrate unrelated changes in one function:

def encode(value):
    if isinstance(value, TypeA):
        ...
    elif isinstance(value, TypeB):
        ...
    elif isinstance(value, TypeC):
        ...

With single dispatch, each implementation can be physically separate:

@encode.register
def encode_type_a(value: TypeA):
    ...


@encode.register
def encode_type_b(value: TypeB):
    ...

This can make independent extensions easier to review because the code for one type does not require modifying the body of another type’s branch.

That benefit is strongest when there is one stable conceptual operation, type-specific implementations are meaningfully independent, new supported types are expected over time, and dispatch is genuinely based on one argument.

If those conditions do not hold, a normal function may remain simpler.

Registration is global to that generic function object

Calling register() changes the registry associated with a specific generic function.

That means registration order and import behavior can matter in extensible applications.

Suppose one module defines the generic function while another module registers a plugin type. The plugin registration exists only after the registration code has executed.

For application-owned code, explicit imports during startup are usually easier to reason about than relying on accidental import order.

For plugin architectures, define a clear registration phase and test that required extensions are loaded before dispatch begins.

Avoid using registrations as hidden monkey patches

Because another module can register an implementation on a generic function it imports, single dispatch can be used as an extension point.

That does not mean arbitrary modules should modify shared generic functions without ownership rules.

Uncoordinated registration can create problems:

  • behavior changes merely because a module was imported;
  • two components may compete to register the same type;
  • tests may depend on process-global registration state;
  • debugging can require tracing where a registration occurred.

Treat a public generic function’s registry as part of its extension contract. Document who may register handlers and when.

If extensions should be isolated per object or configuration, an instance-owned strategy table may be safer than a module-level generic function.

Single dispatch is not the same as method overriding

Object-oriented polymorphism normally places behavior on the receiver:

document.render()

Single dispatch places the operation outside the value:

render(document)

Neither direction is universally better.

Methods are often a natural fit when you own the class, the operation is central to what the class represents, and subclasses should inherit or override that behavior.

Single dispatch can be attractive when you do not own the classes, adding the operation to every class would be awkward, one module owns the operation and supports many external types, or the type hierarchy was designed for another purpose.

Single dispatch is not automatically better than isinstance

A short condition chain is often the clearest implementation.

def normalize(value):
    if isinstance(value, str):
        return value.strip()
    if value is None:
        return ""
    return str(value)

Replacing this with a generic function could add indirection without improving extensibility.

Prefer singledispatch when the registrations form meaningful independent implementations. Do not use it merely to eliminate every occurrence of isinstance().

The goal is not fewer conditionals. The goal is a clearer ownership structure for type-specific behavior.

Be careful with broad fallback registrations

A very broad registration can silently accept types you did not intend to support.

For example, making the default perform permissive conversion such as str(value) means almost any object appears supported.

That may be fine for display code, but it is risky for protocols where unsupported inputs should fail visibly.

A strict default is often safer:

@singledispatch
def encode(value):
    raise TypeError(
        f"unsupported value type: {type(value).__name__}"
    )

Then every supported semantic category is intentional and reviewable.

Test both output and dispatch relationships

Tests should verify the behavior users care about:

assert serialize(42) == "42"
assert serialize(True) == "true"

For important subtype relationships, it can also be useful to verify dispatch directly:

assert serialize.dispatch(bool) is serialize.registry[bool]
assert serialize.dispatch(int) is serialize.registry[int]

For abstract base class registrations, test at least one concrete compatible type.

Also test unsupported values when the default is supposed to reject them.

These tests protect the dispatch contract during refactoring.

Common mistakes

Expecting dispatch on multiple arguments

singledispatch considers the first argument only. If two argument types jointly determine behavior, model that requirement explicitly.

Registering concrete types when an abstraction is the real contract

If an implementation works for mappings, registering only dict unnecessarily excludes other mapping implementations.

Forgetting subclass relationships

A broad registration may handle subclasses you did not immediately consider. bool inheriting from int is a classic example.

Making the default silently accept everything

A permissive fallback can hide unsupported types and missing registrations.

Depending on accidental plugin imports

Registration code must run before its handlers are available. Make extension loading explicit when correctness depends on it.

Replacing simple conditionals with unnecessary indirection

A generic function has value when it clarifies an extensible operation. For two tiny branches, ordinary control flow may be easier to read.

When single dispatch is a good fit

Use singledispatch when these conditions mostly hold:

  1. there is one clear operation with one public entry point;
  2. behavior primarily depends on the runtime type of the first argument;
  3. implementations for different type categories are substantial enough to deserve separation;
  4. supporting new types without rewriting a central condition chain is useful;
  5. the registration lifecycle is understandable.

Prefer another design when behavior depends on several independent arguments, when the operation belongs naturally on classes you control, or when the dispatch table would be more explicit as ordinary data.

Keep dispatch aligned with the domain

functools.singledispatch is a small feature, but it expresses a useful design idea: one operation can have multiple implementations selected from the runtime type hierarchy.

Used well, it separates type-specific code without hiding the public operation. Registrations can target concrete classes or meaningful abstract base classes, subclasses can inherit broader behavior, and more specific handlers can refine it.

The feature works best when the type distinction is real in the domain. If the code only needs a small conditional, keep the conditional. If one stable operation genuinely needs an extensible family of type-specific implementations, single dispatch can make that structure explicit and easier to extend.