Skip to main content

How to Implement Function Overloading with singledispatch decorator in Python

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}")

# Each call dispatches to the appropriate implementation
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
How does Python choose which function to call?

When you call process(x), Python checks the type of x against registered implementations:

  1. If an exact type match exists, it uses that implementation.
  2. If not, Python walks the type's Method Resolution Order (MRO) to find the closest registered parent type.
  3. If no match is found in the MRO, the default (@singledispatch-decorated) function is used.

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
tip

The annotation-based syntax is recommended for Python 3.7+ as it is more readable and keeps the type information close to the parameter.

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}")

# Check which function handles each type
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>
note

The default implementation is registered under object, since all Python types inherit from object.

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()}"'

# Test with various types
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"
Watch out for bool and int ordering

In Python, bool is a subclass of int. If you register handlers for both, the bool handler must be registered to ensure booleans are not caught by the int handler:

# Without a bool handler, True dispatches to the int handler
print(serialize(True)) # Would print "1" instead of "true"

singledispatch correctly handles this through MRO; it matches bool before int because bool is more specific. However, the bool handler must be registered for this to work.

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

FeatureSyntaxDescription
Define default@singledispatchBase implementation for unregistered types
Register by type@func.register(int)Explicit type registration
Register by annotation@func.register + type hintCleaner syntax (Python 3.7+)
Stack registrationsMultiple @func.registerOne function handles multiple types
Check dispatchfunc.dispatch(type)See which implementation handles a type
View all registeredfunc.registryDictionary of all type → function mappings
Class methods@singledispatchmethodSingle 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.