A Python function can accept several argument combinations and return different types depending on the call. A single annotation full of unions often loses the relationship between inputs and outputs. typing.overload declares multiple static signatures for one runtime implementation, improving inference and autocomplete without duplicating behavior.
This guide explains overload declarations, implementation compatibility, Literal, None, generics, keyword-only parameters, ordering, unsafe overlap, methods, Protocol, stubs, and static testing with mypy or pyright.
The problem with a broad union
def lookup(key: int | str) -> int | str:
if isinstance(key, int):
return key * 10
return key.upper()
result = lookup(2)
# A checker may see int | strAt runtime, an int always produces int and a str always produces str. The simple annotation does not preserve that relationship.
A first overload
from typing import overload
@overload
def lookup(key: int) -> int: ...
@overload
def lookup(key: str) -> str: ...
def lookup(key: int | str) -> int | str:
if isinstance(key, int):
return key * 10
return key.upper()The decorated functions are declarations for the checker. The final undecorated function is the implementation that runs.
Declarations do not contain runtime logic
Use ... or an empty declaration body. Do not place real behavior inside overload variants because callers do not execute those bodies.
@overload
def convert(value: bytes) -> str: ...The implementation must follow all variants in the same definition block.
Implementation compatibility
The implementation must accept every call described by the overloads and return every promised type.
@overload
def convert(value: int) -> str: ...
@overload
def convert(value: bytes) -> str: ...
def convert(value: int | bytes) -> str:
if isinstance(value, bytes):
return value.decode()
return str(value)If one variant accepts bytes, the implementation cannot accept only int. If a variant promises str, the corresponding runtime path must not return None.
Overload with Literal
Literal is one of the most useful companions because the result depends on an exact option.
from typing import Literal, overload
@overload
def load(path: str, *, binary: Literal[False] = False) -> str: ...
@overload
def load(path: str, *, binary: Literal[True]) -> bytes: ...
def load(path: str, *, binary: bool = False) -> str | bytes:
mode = "rb" if binary else "r"
with open(path, mode) as file:
return file.read()A call with binary=True returns bytes; False or omission returns str.
A non-literal bool
option: bool = read_configuration()
result = load("data.txt", binary=option)The option may be true or false, so the correct result is str | bytes. Some APIs add a broader bool overload after the Literal variants to make this case explicit.
Ordering variants
Place specific variants before general ones. A broad overload first can make later variants unreachable for overload resolution.
@overload
def parse(value: Literal["auto"]) -> AutomaticConfiguration: ...
@overload
def parse(value: str) -> Configuration: ...Literal["auto"] is more specific than str and belongs first.
Overlapping variants
Two variants may accept the same call. If their returns are incompatible, the API becomes ambiguous.
@overload
def example(value: int) -> int: ...
@overload
def example(value: object) -> str: ...An int is also an object. Both variants match, but the returns differ. A checker may prefer the first match, yet overlap should be intentional and type-safe. Prefer compatible returns or redesign the interface.
bool is a subtype of int
@overload
def format_value(value: bool) -> str: ...
@overload
def format_value(value: int) -> bytes: ...Order bool first. The runtime implementation must also test bool before int if behavior differs.
Overload with None
@overload
def normalize(value: None) -> None: ...
@overload
def normalize(value: str) -> str: ...
def normalize(value: str | None) -> str | None:
if value is None:
return None
return value.strip().casefold()The checker preserves None for None input and str for string input.
Defaults and omitted arguments
APIs that distinguish an omitted argument from an explicit None often need a private sentinel.
from typing import TypeVar
T = TypeVar("T")
_MISSING = object()
@overload
def read_option(name: str) -> str: ...
@overload
def read_option(name: str, default: T) -> str | T: ...
def read_option(name: str, default: object = _MISSING) -> object:
if name in configuration:
return configuration[name]
if default is _MISSING:
raise KeyError(name)
return defaultThe sentinel prevents confusion between “not provided” and “provided as None.”
Generic overloads
from collections.abc import Iterable
from typing import TypeVar
T = TypeVar("T")
@overload
def first(values: tuple[T, ...]) -> T: ...
@overload
def first(values: list[T]) -> T: ...
def first(values: Iterable[T]) -> T:
return next(iter(values))In this example, a single generic Iterable signature might be enough. Do not use overload when a TypeVar expresses the relationship more simply.
When TypeVar replaces overload
T = TypeVar("T")
def identity(value: T) -> T:
return valueWriting separate variants for int, str, bytes, and every future type would be unnecessary. Overload is for genuinely different call forms, not for enumerating arbitrary types.
Preserving sequence types
@overload
def slice_data(data: str, start: int, end: int) -> str: ...
@overload
def slice_data(data: bytes, start: int, end: int) -> bytes: ...
def slice_data(data: str | bytes, start: int, end: int) -> str | bytes:
return data[start:end]A constrained TypeVar may also express this relation. Choose the version that is clearest and best supported by the checker.
Keyword-only parameters
@overload
def query(id_value: int, *, full: Literal[False] = False) -> Summary: ...
@overload
def query(id_value: int, *, full: Literal[True]) -> FullRecord: ...Variants must preserve the keyword-only nature. The implementation needs a compatible public interface.
Parameter names matter
Names are part of keyword calls. Keep them consistent across variants and implementation.
@overload
def open_resource(source: str) -> Resource: ...
@overload
def open_resource(source: Path) -> Resource: ...Using different names for the same position makes the API confusing and can produce checker errors.
Overloaded methods
class Cache:
@overload
def get(self, key: str) -> object: ...
@overload
def get(self, key: str, default: T) -> object | T: ...
def get(self, key: str, default: object = _MISSING) -> object:
...self appears in every variant. For classmethods and staticmethods, apply decorators consistently according to the checker and supported Python version.
Overload and Self
Factory methods may use overloads when different options produce distinct return types. When a method simply returns the current concrete class, typing.Self is usually simpler. See the guide to Python typing.Self.
Overload in Protocol
from typing import Protocol
class Parser(Protocol):
@overload
def parse(self, data: str) -> Document: ...
@overload
def parse(self, data: bytes) -> BinaryDocument: ...A structural implementation must be compatible with the complete set of signatures.
Overload in stub files
Stub files do not contain the normal Python implementation. Overload variants can represent the entire public interface of a function implemented in C, dynamically generated, or difficult to annotate directly.
Runtime behavior
Overload variants are not normally callable. The final implementation replaces the name in the module. Modern typing versions offer introspection such as get_overloads(), mainly for tooling.
from typing import get_overloads
variants = get_overloads(load)Do not build core business logic around this registry. Overload remains primarily a static construct.
Overload versus singledispatch
functools.singledispatch selects runtime implementations based on the first argument’s type. overload only describes signatures to a checker. They solve different problems and may be combined when needed.
Overload does not implement dispatch
@overload
def process(value: int) -> int: ...
@overload
def process(value: str) -> str: ...Without a following implementation, there is no useful runtime function. The decorator does not automatically choose a variant.
Decorators and overload
A decorator applied to an overloaded API must preserve every signature. ParamSpec helps generic signature-preserving decorators, while complex public APIs may require explicit overloads on the decorator. See the guide to Python ParamSpec.
A return depending on two arguments
@overload
def combine(a: str, b: str) -> str: ...
@overload
def combine(a: bytes, b: bytes) -> bytes: ...
def combine(a: str | bytes, b: str | bytes) -> str | bytes:
if type(a) is not type(b):
raise TypeError("incompatible types")
return a + bThe implementation accepts a broad union that includes invalid mixed calls and rejects them at runtime. This is common as long as every declared call is accepted and extra combinations are handled deliberately.
Documentation
Documentation tools may show the implementation or overload variants. Write a docstring explaining the input-output relationship, errors, and behavior. The signatures alone do not communicate side effects or runtime validation.
Too many variants
Large overload sets increase maintenance and checker cost. If dozens of combinations are needed, consider configuration objects, separate methods, builders, generics, or a clearer API.
Common mistakes
- Forgetting the implementation: overload does not create runtime dispatch.
- Putting logic in variants: callers execute the final implementation.
- Using an incompatible implementation: some promised calls cannot run.
- Putting the general variant first: specific variants may never match.
- Creating unsafe overlap: inference becomes ambiguous.
- Using overload where TypeVar is enough: the API becomes repetitive.
Complete format-dependent deserialization
from dataclasses import dataclass
from typing import Literal, overload
import json
@dataclass
class Configuration:
name: str
active: bool
@overload
def deserialize(
data: str,
*,
format: Literal["json"],
) -> dict[str, object]: ...
@overload
def deserialize(
data: bytes,
*,
format: Literal["binary"],
) -> Configuration: ...
def deserialize(
data: str | bytes,
*,
format: Literal["json", "binary"],
) -> dict[str, object] | Configuration:
if format == "json":
if not isinstance(data, str):
raise TypeError("json requires str")
result = json.loads(data)
if not isinstance(result, dict):
raise ValueError("JSON object expected")
return result
if not isinstance(data, bytes):
raise TypeError("binary requires bytes")
name, active = decode_record(data)
return Configuration(name=name, active=active)The variants expose only valid combinations and precise returns. The implementation still validates runtime relationships because untyped callers can bypass static analysis.
Testing overloads
text = load("a.txt")
reveal_type(text) # str
binary_data = load("a.bin", binary=True)
reveal_type(binary_data) # bytesKeep type-checking fixtures with valid and invalid calls, and run mypy or pyright in continuous integration. Runtime tests should cover the implementation and dynamic invalid combinations.
Best practices
Start from the caller’s contract. Use a small number of specific variants. Order them from most specific to most general. Keep names and defaults consistent. Ensure the implementation covers every declaration. Prefer TypeVar, Protocol, ParamSpec, or separate methods when they express the relationship more simply.
Conclusion
typing.overload describes multiple static signatures for one implementation and preserves precise relationships between arguments and return types. It is especially useful with Literal, None, alternative formats, defaults, and compatibility APIs.
The official Python overload documentation defines the rules. Use it to model genuinely distinct calls, not as a replacement for simple design or runtime validation.







