@overload vs Union Return Types
TL;DR — Use @overload when the return type depends on the argument types, so a caller passing str gets back str and one passing bytes gets back bytes. Use a plain union return (-> str | bytes) only when the result type does not track the input — the caller then has to narrow it themselves. Overloads give precise call-site inference; a union return throws that precision away.
The two look similar because both describe a function that can return more than one type. The difference is correlation: does the specific return type follow from the specific argument type? If yes, overloads encode that relationship and callers get an exact type; if no, a union is honest and simpler. This page, part of Advanced Typing Patterns & Generics, shows both spellings on a realistic decode function and the overlapping-overload trap that mypy flags.
When the return type depends on the input: overload
A decode helper that returns str for text input and bytes for binary input has a return type that correlates with its argument. Overloads capture that: list each input/output pairing as an @overload stub, then write one real implementation.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import overload
@overload
def decode(payload: str) -> str: ...
@overload
def decode(payload: bytes) -> bytes: ...
def decode(payload: str | bytes) -> str | bytes:
if isinstance(payload, str):
return payload.strip()
return payload.rstrip()
reveal_type(decode(" hi ")) # str
reveal_type(decode(b"\x00hi")) # bytes
Each call site gets a single precise type. decode("...") is a str — no narrowing needed — so downstream code like decode("x").upper() type-checks directly. The overload stubs are the public contract; the untyped-looking body below them is the implementation and is not itself callable with the broad str | bytes signature.
When it does not: a plain union return
Now compare the imprecise alternative. If decode were annotated with a bare union return, every call collapses to str | bytes regardless of what you passed.
# Python 3.11+, checked with mypy 1.10
def decode_loose(payload: str | bytes) -> str | bytes:
if isinstance(payload, str):
return payload.strip()
return payload.rstrip()
result = decode_loose(" hi ")
reveal_type(result) # str | bytes — precision lost
result.upper() # mypy error: [union-attr] — bytes has no "upper"
Because the checker no longer knows the result is a str, calling .upper() fails with [union-attr] (pyright: reportAttributeAccessIssue) — you would have to re-narrow with isinstance at every call site. A union return is the right tool only when the output genuinely is not determined by the input, for example a parser that may return a value or an error object regardless of argument type. In that case a union — or a union/Optional type — states the truth and overloads would be a lie.
The overlapping-overload pitfall
Overloads are matched top-to-bottom, and the checker verifies the set is consistent. Two overloads whose parameter types overlap but whose return types are incompatible are an error, because the same argument could match either signature with conflicting results.
# Python 3.11+, checked with mypy 1.10
from typing import overload
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: bool) -> str: ... # bool is a subtype of int!
def parse(value: int) -> int | str:
return str(value) if isinstance(value, bool) else value
# mypy error: [overload-overlap] — overlapping overloads with incompatible returns
Because bool is a subclass of int, a True argument matches the first overload (returning int) before ever reaching the second — mypy reports [overload-overlap] (older mypy: [misc]), pyright reportOverlappingOverloads. Order the more specific overload first, or make the return types compatible, to resolve it.
@overload stubs vanish at runtime — only the final implementation actually runs, and it must handle every input the overloads advertise. Python does no dispatch based on the overloads; if your implementation body forgets a branch, the checker still trusts your stubs and the bug surfaces only at execution. Keep the implementation signature broad enough to cover every overload.
Common mistakes
- A single
@overloadwith no others: at least two overload stubs plus one implementation are required; a lone stub triggers[misc]in mypy. If there is only one signature, you do not need overloads at all. - Forgetting the implementation: overload stubs without a following concrete definition raise
[misc]/reportGeneralTypeIssues— the stubs are declarations, not the runnable function. - Overlapping overloads with incompatible returns:
[overload-overlap]in mypy,reportOverlappingOverloadsin pyright. Put the narrower parameter type first. - Reaching for overloads when a union suffices: if the return type does not depend on the argument type, overloads add noise; a
-> str | bytesunion is clearer and cheaper to maintain.
FAQ
Do overloads change what the function returns at runtime? No. Overloads are pure static declarations; the single implementation body is the only code that runs. They exist so callers get a precise type, not to alter behavior — you still write one function that handles all cases.
Can I use @overload with generics or TypeVars instead?
Sometimes a TypeVar expresses the correlation more compactly — e.g. def identity(x: T) -> T. Reach for overloads when the input/output mapping is a small set of distinct concrete pairings (like str→str, bytes→bytes) that a single TypeVar cannot capture.