Python Overload Type Hints: Multiple Signatures
python overload type hints: Learn how typing.overload lets you declare multiple type signatures for one Python function, improving type checking accuracy at every call...
When a Python function accepts different argument types and returns different result types depending on the input, a single type hint often cannot express the relationship precisely. Python overload type hints, implemented with typing.overload, solve this by letting you declare multiple type signatures for one function. Static type checkers such as mypy and Pyright then infer the correct return type at each call site based on the argument types.
What typing.overload Declares
The @overload decorator marks a function stub as a type-signature declaration. You write several decorated stubs, each with a distinct signature, followed by one undecorated implementation. The stubs are never executed; they exist only for static analysis.
from typing import overload @overload def parse_config(path: str) -> dict: ... @overload def parse_config(raw: bytes) -> dict: ... def parse_config(source: str | bytes) -> dict: if isinstance(source, bytes): source = source.decode("utf-8") return json.loads(source)
The two decorated stubs are ignored at runtime. The final undecorated definition is the real implementation and is the only code that runs when the function is called.
How Type Checkers Pick an Overload
Type checkers evaluate overloads in declaration order and select the first signature that matches the call. Ordering is therefore part of the API contract.
Consider this reversed ordering:
@overload def convert(value: object) -> str: ... @overload def convert(value: int) -> int: ... def convert(value: object) -> object: return value if isinstance(value, int) else str(value)
A call like convert(42) matches the first overload because int is a subtype of object, so the checker infers the return type as str instead of int. The narrower overload never gets a chance. Put more specific signatures first:
@overload def convert(value: int) -> int: ... @overload def convert(value: object) -> str: ... def convert(value: object) -> object: return value if isinstance(value, int) else str(value)
Now convert(42) is inferred as int, and any non-int argument is inferred as str.
Rules the Implementation Must Follow
Several constraints apply when you use typing.overload:
- All overload declarations must appear before the implementation.
- The implementation must be the last definition of the name.
- The implementation's signature must be broad enough to accept every argument combination covered by the overloads, typically using union types or
Any. - The decorated stubs must not contain a body; the ellipsis
...is the convention.
If you forget the implementation and call the function at runtime, Python raises NotImplementedError because typing.overload leaves the stub function in place. This is a common failure mode during refactoring.
The union syntax str | bytes in the implementation signature requires Python 3.10 or later. On older versions, use typing.Union[str, bytes]. The @overload decorator itself is part of the standard typing module and works in all currently supported Python releases.
A Practical Example: Parsing Different Input Formats
A realistic use case is a parser that accepts several input formats and returns a normalized structure:
from typing import overload, TextIO import io, json @overload def load_events(path: str) -> list[dict]: ... @overload def load_events(raw: bytes) -> list[dict]: ... @overload def load_events(stream: TextIO) -> list[dict]: ... def load_events(source: str | bytes | TextIO) -> list[dict]: if isinstance(source, bytes): source = source.decode("utf-8") if isinstance(source, str): source = io.StringIO(source) return [json.loads(line) for line in source]
Each overload narrows the accepted input type, and the checker knows the return type is list[dict] regardless of which branch is taken. Without overloads, the caller would have to narrow the argument manually before the return type could be trusted.
Combining Overloads with TypeVars
Overloads become more powerful when combined with TypeVar to express relationships between generic types:
from typing import TypeVar, overload T = TypeVar("T") @overload def first(items: list[T]) -> T: ... @overload def first(items: tuple[T, ...]) -> T: ... def first(items: list[T] | tuple[T, ...]) -> T: return items[0]
Here each overload captures the element type from the container type. A call like first([1, 2, 3]) is inferred as int, and first(("a", "b")) is inferred as str. A single signature with Sequence[T] would also work, but overloads let you restrict the accepted container types explicitly.
Runtime Cost and Introspection Behavior
typing.overload has no runtime performance cost. The decorator returns the stub function unchanged; it does not wrap it or register it anywhere. The implementation replaces the stubs in the module namespace, so calling the function executes only the implementation body.
One runtime caveat: tools that inspect function signatures, such as inspect.signature, see the implementation's signature, not the overloads. If your code relies on runtime signature introspection to generate documentation or validate arguments, overloads will not be visible to those tools. Static type checkers are the intended consumers.
When Overloads Add More Noise Than Value
Overloads are not the right tool for every function with multiple input types. If the return type does not depend on the argument types, a union type in a single signature is simpler:
def normalize(value: str | bytes) -> str: return value.decode() if isinstance(value, bytes) else value
Adding overloads here would duplicate the signature for no benefit. Similarly, if the relationship between input and output can be expressed with a TypeVar, prefer the TypeVar:
T = TypeVar("T") def identity(value: T) -> T: return value
Overloads earn their place when the return type genuinely varies by argument type, or when you need to restrict argument combinations that a single signature cannot express. Overusing them makes the module harder to maintain because every signature change must be applied in multiple places. When the set of accepted input types grows, review whether the overloads still reflect the implementation's actual behavior; a mismatch between the declared signatures and the runtime logic is a common source of subtle type-checker bugs.