Skip to content

How to Implement Function Overloading with singledispatch decorator in Python

Learn how to use Python's @singledispatch decorator to create type-based function overloading, register specialized implementations, and handle default fallbacks for clean, extensible code.

Python does not natively support function overloading: defining multiple versions of a function with different parameter types, as you might do in Java or C++. However, Python's functools module provides the @singledispatch decorator, which enables single-dispatch generic functions. This lets a function behave differently based on the type of its first argument, giving you a clean, Pythonic way to implement overloading.

This guide covers how to use @singledispatch, register implementations for different types, stack registrations, and inspect the dispatch mechanism.

What Is Single Dispatch?

Single dispatch means the function implementation is chosen based on the type of a single argument, specifically the first argument. The decorated function serves as the default (fallback) implementation, and you register specialized versions for specific types.

from functools import singledispatch

@singledispatch
def process(value):
    """Default implementation for unregistered types."""
    print(f"Default: {value}")

print(process({"key": "value"}))

Output:

Default: {'key': 'value'}
None

Since no specific implementation is registered for dict, the default function is called.

Registering Type-Specific Implementations

Use the @function.register(type) decorator to add specialized behavior for specific types:

from functools import singledispatch

@singledispatch
def process(value):
    """Default implementation."""
    print(f"Default: {value}")

@process.register(int)
def _process_int(value):
    print(f"Integer: {value * 2}")

@process.register(str)
def _process_str(value):
    print(f"String: '{value.upper()}'")

@process.register(list)
def _process_list(value):
    for i, item in enumerate(value):
        print(f"  [{i}] {item}")

process(42)
process("hello")
process(["a", "b", "c"])
process(3.14)  # No float registered; falls back to default

Output:

Integer: 84
String: 'HELLO'
  [0] a
  [1] b
  [2] c
Default: 3.14

Registering Multiple Types for One Implementation

You can stack @register decorators to handle multiple types with the same implementation:

from functools import singledispatch
from decimal import Decimal

@singledispatch
def format_number(value):
    print(f"Unsupported type: {type(value).__name__}")

@format_number.register(float)
@format_number.register(Decimal)
def _format_decimal(value):
    print(f"Rounded: {round(value, 2)}")

format_number(3.14159)
format_number(Decimal("4.897"))
format_number(42)  # Falls back to default

Output:

Rounded: 3.14
Rounded: 4.90
Unsupported type: int

Both float and Decimal types are handled by the same _format_decimal function.

Using Type Annotations (Python 3.7+)

Starting with Python 3.7, you can use type annotations instead of passing the type to register(), making the code cleaner:

from functools import singledispatch

@singledispatch
def describe(value):
    print(f"Something: {value}")

@describe.register
def _describe_int(value: int):
    print(f"An integer: {value}")

@describe.register
def _describe_str(value: str):
    print(f"A string of length {len(value)}: '{value}'")

@describe.register
def _describe_list(value: list):
    print(f"A list with {len(value)} items")

describe(42)
describe("hello")
describe([1, 2, 3])
describe(3.14)

Output:

An integer: 42
A string of length 5: 'hello'
A list with 3 items
Something: 3.14

Inspecting the Dispatch Mechanism

singledispatch provides tools to inspect how dispatch works at runtime.

Using .dispatch() to Check Which Implementation Is Selected

The .dispatch(type) method returns the function that would be called for a given type:

from functools import singledispatch

@singledispatch
def process(value):
    print(f"Default: {value}")

@process.register(int)
def _process_int(value):
    print(f"Integer: {value}")

@process.register(list)
def _process_list(value):
    print(f"List: {value}")

print(process.dispatch(int))    # → _process_int
print(process.dispatch(list))   # → _process_list
print(process.dispatch(dict))   # → process (default, no dict registered)

Output:

<function _process_int at 0x7cda41a7c680>
<function _process_list at 0x7cda41a7c720>
<function process at 0x7cda41a34220>

Using .registry to View All Registered Types

The .registry attribute is a read-only dictionary mapping types to their implementations:

print(process.registry.keys())
print(process.registry[int])
print(process.registry[object])  # The default implementation

Output:

dict_keys([<class 'object'>, <class 'int'>, <class 'list'>])
<function _process_int at 0x7eec955a0680>
<function process at 0x7eec95558220>

Practical Example: Serializing Different Data Types

A realistic use case for singledispatch is building a custom serializer that converts different Python types to strings:

from functools import singledispatch
from datetime import datetime, date

@singledispatch
def serialize(value):
    """Default: convert to string."""
    return str(value)

@serialize.register
def _serialize_str(value: str):
    return f'"{value}"'

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

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

@serialize.register
def _serialize_list(value: list):
    items = ", ".join(serialize(item) for item in value)
    return f"[{items}]"

@serialize.register
def _serialize_dict(value: dict):
    pairs = ", ".join(f"{serialize(k)}: {serialize(v)}" for k, v in value.items())
    return f"{{{pairs}}}"

@serialize.register
def _serialize_datetime(value: datetime):
    return f'"{value.isoformat()}"'

print(serialize("hello"))
print(serialize(42))
print(serialize(True))
print(serialize([1, "two", 3]))
print(serialize({"name": "Alice", "age": 30}))
print(serialize(datetime.now()))

Output:

"hello"
42
true
[1, "two", 3]
{"name": "Alice", "age": 30}
"2026-02-15T09:50:39.374720"

singledispatchmethod for Class Methods (Python 3.8+)

Starting with Python 3.8, functools.singledispatchmethod brings single dispatch to class methods:

from functools import singledispatchmethod

class Formatter:
    @singledispatchmethod
    def format(self, value):
        return f"Unknown: {value}"

    @format.register
    def _format_int(self, value: int):
        return f"Integer: {value:,}"

    @format.register
    def _format_str(self, value: str):
        return f"String: '{value}'"

fmt = Formatter()
print(fmt.format(1000000))
print(fmt.format("hello"))
print(fmt.format(3.14))

Output:

Integer: 1,000,000
String: 'hello'
Unknown: 3.14

Quick Reference

Feature Syntax Description
Define default @singledispatch Base implementation for unregistered types
Register by type @func.register(int) Explicit type registration
Register by annotation @func.register + type hint Cleaner syntax (Python 3.7+)
Stack registrations Multiple @func.register One function handles multiple types
Check dispatch func.dispatch(type) See which implementation handles a type
View all registered func.registry Dictionary of all type → function mappings
Class methods @singledispatchmethod Single dispatch in classes (Python 3.8+)

Conclusion

The @singledispatch decorator provides an elegant, Pythonic way to implement function overloading based on the type of the first argument.

  • It encourages clean code architecture by separating type-specific logic into focused, registered functions while maintaining a sensible default fallback.
  • For class-based designs, @singledispatchmethod extends the same pattern to instance methods.

Whether you're building serializers, formatters, or type-specific processors, singledispatch keeps your code organized, extensible, and easy to maintain.

Navigation