Python overload: Precise Function Signatures

Published on: August 29, 2026
Reading time: 6 minutes
Detailed view of programming code in a dark theme on a computer screen.

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 | str

At 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 default

The 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 value

Writing 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 + b

The 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)  # bytes

Keep 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.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    A person reads 'Python for Unix and Linux System Administration' indoors.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ClassVar: Separate Class and Instance State

    Learn Python ClassVar to separate class and instance state in dataclasses, registries, caches, inheritance, and counters.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    A person typing on a laptop with a Python programming book visible, capturing technology and learning.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Final: Protect Constants and Inheritance

    Learn Python Final and @final to protect constants, attributes, methods, and classes while understanding runtime limitations.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    High-angle view of woman coding on a laptop, with a Python book nearby. Ideal for programming and tech content.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Annotated: Add Type Metadata

    Learn Python Annotated to attach metadata to types for validation, schemas, units, documentation, and framework integration.

    Ler mais

    Tempo de leitura: 6 minutos
    29/08/2026
    Detailed view of programming code in a dark theme on a computer screen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python NewType: Keep IDs Distinct

    Learn Python NewType to keep IDs, codes, units, and primitive values distinct, validate boundaries, and prevent domain mix-ups.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Close view of a python resting on sandy terrain outdoors in natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Never: Mark Unreachable Code

    Learn Python Never for non-returning functions, unreachable code, and exhaustive unions with assert_never, Literal, Enum, and match.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Detailed shot of a Jungle Carpet Python (Morelia spilota cheynei) in its natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Concatenate: Change Parameters

    Learn Python Concatenate to add or hide leading parameters in typed decorators with ParamSpec, contexts, locks, and dependencies.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026