A function often starts with one input type and later grows branches for several related types. The first version may be straightforward:

def render(value):
    if isinstance(value, str):
        ...
    elif isinstance(value, list):
        ...
    elif isinstance(value, dict):
        ...

As the number of supported types grows, this function becomes the place where every extension must be added. The branches mix dispatch logic with the behavior for each type, and independently maintained modules cannot add support without editing the central function.

Python’s functools.singledispatch offers a different model. It turns one function into a single-dispatch generic function: a callable that chooses an implementation from the runtime type of its first argument.

This is useful when the operation is conceptually one thing, such as formatting, exporting, serializing, or validating, but the implementation naturally varies by input type.

Start with one generic operation and one specialized type

The smallest useful example has a default implementation and one registered specialization:

from functools import singledispatch

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

@describe.register
def _(value: str):
    return f"text with {len(value)} characters"

Now the same public function handles both cases:

print(describe("hello"))
print(describe(42))

The output is:

text with 5 characters
object of type int

The decorated describe function is the generic function. The original body becomes the fallback implementation for object, so it is used when no more specific registration matches.

The registered function is named _ because callers do not need to invoke it directly. The registration attaches it to describe, while describe remains the public entry point.

Dispatch depends only on the first argument’s runtime type

The key mental model is precise: singledispatch chooses an implementation from the type of the first positional argument.

Consider a formatter that also accepts an option:

from functools import singledispatch

@singledispatch
def format_value(value, *, compact=False):
    return repr(value)

@format_value.register
def _(value: list, *, compact=False):
    separator = "," if compact else ", "
    return "[" + separator.join(map(str, value)) + "]"

The compact argument changes what the selected implementation does, but it does not participate in dispatch.

format_value([1, 2, 3], compact=True)

selects the list implementation because the first argument is a list.

If an operation needs to choose behavior from two independent runtime types, singledispatch is usually the wrong abstraction. A lookup table keyed by both types, explicit branching, or another design may communicate the rule more clearly.

Registration can be explicit or annotation-driven

The annotation-based form is concise:

@describe.register
def _(value: bytes):
    return f"{len(value)} bytes"

The type annotation on the first parameter tells register which type to associate with the function.

Registration can also name the type explicitly:

@describe.register(bytes)
def describe_bytes(value):
    return f"{len(value)} bytes"

The explicit form is useful when the implementation signature cannot conveniently express the dispatch type or when you want a descriptive function name for testing.

The registered functions are still normal functions. The decorator form returns the undecorated implementation, which makes an individual specialization directly testable without going through dispatch.

singledispatch does not require an exact type match. If the concrete type has no direct registration, Python looks for a matching implementation through the type hierarchy.

For example:

from collections.abc import Mapping
from functools import singledispatch

@singledispatch
def summarize(value):
    return "unsupported"

@summarize.register
def _(value: Mapping):
    return f"mapping with {len(value)} entries"

A normal dictionary is a Mapping, so this call uses the registered implementation:

summarize({"status": "ok"})

This is more reusable than registering only dict when the operation truly works for any object satisfying the Mapping interface.

Abstract base classes matter here. A registration for an abstract base class can match concrete or virtual subclasses recognized by that abstraction. That makes registrations such as Mapping, Sequence, or other appropriate interfaces useful when the implementation depends on a capability rather than one concrete container class.

Prefer the broadest type that the implementation genuinely supports. Registering an interface when the code secretly assumes methods unique to one implementation creates runtime failures that the type relationship did not justify.

The most specific applicable registration wins

Suppose both a broad base class and a narrower subclass are registered:

from functools import singledispatch

class Event:
    pass

class UserEvent(Event):
    pass

@singledispatch
def route(event):
    return "default"

@route.register
def _(event: Event):
    return "event"

@route.register
def _(event: UserEvent):
    return "user-event"

route(UserEvent()) uses the UserEvent implementation, while another Event subclass without its own registration uses the broader Event implementation.

This is what makes the mechanism useful as an extension point: a library can provide a general implementation while a more specific type adds a narrower one.

However, dispatch should not become a substitute for clear domain modeling. If many overlapping registrations make it difficult to predict which implementation applies, the extension point is carrying too much responsibility.

A realistic use case: exporting different document objects

Imagine an application with several document representations. Callers should ask for export bytes without knowing the concrete class:

from dataclasses import dataclass
from functools import singledispatch
import json

@dataclass(frozen=True)
class TextDocument:
    text: str

@dataclass(frozen=True)
class JsonDocument:
    data: dict[str, object]

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

@export_bytes.register
def _(document: TextDocument):
    return document.text.encode("utf-8")

@export_bytes.register
def _(document: JsonDocument):
    return json.dumps(
        document.data,
        separators=(",", ":"),
        sort_keys=True,
    ).encode("utf-8")

The default raises instead of silently guessing. That is appropriate when exporting an unsupported object would be a programming error.

Callers use one stable interface:

payload = export_bytes(JsonDocument({"ready": True}))

This design separates three concerns:

  • Callers know the operation: export a document.
  • Document classes hold document data.
  • Registered functions own type-specific export behavior.

A new document type can be supported by registering another implementation without adding another branch to the generic function.

Registrations can live outside the module that defines the generic function

A useful property of singledispatch is that registration is not limited to the original module.

Suppose exports.py defines export_bytes. A plugin module can add support for its own type:

from exports import export_bytes

class CsvDocument:
    def __init__(self, rows):
        self.rows = rows

@export_bytes.register(CsvDocument)
def export_csv(document):
    lines = [",".join(row) for row in document.rows]
    return ("\n".join(lines) + "\n").encode("utf-8")

Importing that module executes the registration.

This can reduce central coupling, but it introduces an operational requirement: the module containing the registration must actually be imported before dispatch occurs. If plugin discovery or import order is implicit, behavior can depend on application startup details.

For larger systems, make registration loading explicit. For example, have startup code import known plugins or call well-defined plugin initialization functions. Hidden import side effects are harder to debug than an explicit extension lifecycle.

Use dispatch() and registry to inspect behavior

A generic function exposes tools that help with testing and debugging.

dispatch() shows which implementation would be selected for a type:

implementation = export_bytes.dispatch(TextDocument)
assert implementation(TextDocument("hi")) == b"hi"

The read-only registry mapping exposes registered dispatch types:

print(export_bytes.registry.keys())

These are especially useful when multiple modules register implementations and you need to verify that startup loaded the expected extensions.

Do not build application logic around repeatedly inspecting the registry if a normal function call expresses the intent. The registry is most valuable for diagnostics, tests, and extension management.

Watch for the bool and int relationship

Python’s bool is a subclass of int. That can produce surprising dispatch if an int handler is broader than intended:

from functools import singledispatch

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

@encode.register
def _(value: int):
    return "integer"

With only that registration:

encode(True)

returns "integer" because bool is an int subclass.

If booleans require distinct semantics, register them explicitly:

@encode.register
def _(value: bool):
    return "boolean"

This is not special behavior invented by singledispatch; it follows Python’s type hierarchy. The broader lesson is to review inheritance relationships when registrations carry meaning beyond ordinary method substitution.

Avoid using dispatch as validation

Dispatch answers one question: which implementation corresponds to the first argument’s type?

It does not validate the internal state of that object.

For example, a registered Path implementation still needs to handle a missing file if that condition matters. A registered Mapping implementation still needs to validate required keys if its logic depends on them.

Keep type selection and value validation separate. Otherwise a successful dispatch can create the false impression that an input is fully valid.

singledispatchmethod applies the same idea to methods

For class APIs, functools.singledispatchmethod provides the related method form. Dispatch uses the first argument after self or cls.

from functools import singledispatchmethod

class Printer:
    @singledispatchmethod
    def print_value(self, value):
        return repr(value)

    @print_value.register
    def _(self, value: str):
        return value

This is useful when the generic operation conceptually belongs to an object that also carries state or dependencies.

If the operation does not need instance state, a module-level singledispatch function is often simpler. Do not introduce a class only to host dispatch.

Common mistakes make extension points harder to reason about

Dispatching on configuration instead of type

If behavior differs because of a mode such as "json" versus "csv", that is not inherently a type-dispatch problem. A dictionary of handlers keyed by mode is often clearer.

Use singledispatch when runtime type is the meaningful distinction.

Registering types that the implementation only partly supports

A handler registered for Mapping should work for the mapping interface it claims to accept. If it requires a dict-specific behavior, register dict or rewrite the implementation around the abstraction.

Hiding registration behind accidental imports

Registrations performed by plugin imports only exist after those imports execute. Make extension loading deliberate when application correctness depends on it.

Turning one generic function into an application-wide router

A generic function should represent one coherent operation. If it chooses database backends, UI behavior, authorization rules, and serialization all at once, the abstraction has become a service locator rather than a focused dispatch mechanism.

When singledispatch is a good fit

Use it when all of these conditions are mostly true:

  • There is one coherent operation with several type-specific implementations.
  • The first argument’s runtime type is the natural selection rule.
  • New supported types may be added independently.
  • A fallback behavior is meaningful, including a deliberate error.
  • Inheritance-based fallback matches the domain.

A normal if statement is often better for two small cases that are unlikely to grow. Methods on the classes themselves are often better when each type owns the operation and you control those classes. A protocol or abstract base class may be better when every implementation must expose the same behavior as part of its public contract.

singledispatch is strongest when the operation belongs neither to one concrete type nor to a central chain of conditionals, and when runtime type is the stable extension boundary.

Conclusion

functools.singledispatch replaces manual type branches with a generic function whose implementations are registered by type. Dispatch uses the first argument, follows Python’s type hierarchy when choosing a compatible registration, and falls back to the original implementation when no narrower match exists.

That gives Python programs a small, standard-library extension mechanism for operations that naturally vary by input type. Keep the operation focused, register only types the implementation truly supports, make plugin loading explicit, and choose a simpler design when the selection rule is not actually about type.