@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. reveal_type confirms the inference under both checkers: mypy prints Revealed type is "builtins.str" and pyright prints str, and neither will let a caller treat decode("x") as bytes.
@overload has been part of the standard library since PEP 484 introduced it for Python 3.5, and it lives in typing, so from typing import overload works on every supported interpreter from 3.8 through 3.13. The identical decorator is re-exported from typing_extensions for consistency with other backported symbols, but for overload itself you never need the third-party import. The decorator is essentially a flag for the type checker: it records the signature and, at runtime, wraps the function so that calling an overload stub directly raises NotImplementedError. That is why the stub bodies are written as ... and why, in a regular .py module, they must be followed by a concrete, non-@overload implementation — the implementation is the object Python binds to the name and the only version that runs.
In a stub file (.pyi) the rule relaxes. Because a stub never executes, you list the @overload signatures with no implementation at all and type checkers accept it; this is exactly how the standard-library stubs describe functions whose result tracks an argument, such as open, whose return type (TextIOWrapper for text mode versus BufferedReader for binary mode) is spelled out as an overload set keyed on the mode string. Inside a normal module, omitting the implementation is an error rather than an option — see the Common mistakes below.
Resolution is order-sensitive: the checker walks the overloads top-to-bottom and commits to the first one whose parameters accept the call, adopting that overload’s return type. Argument form counts as much as argument type — an overload declared with keyword-only or positional-only parameters only matches calls that pass the arguments that way, so you can deliberately split overloads on calling convention and have decode(payload=b"x") and decode(b"x") resolve differently. Note that from __future__ import annotations (PEP 563) changes how annotations are stored — as strings — not how overloads are resolved, so stringized annotations leave all of this untouched. Overloads apply to methods as well, including self-typed ones: a builder or descriptor whose result type depends on how the instance was parameterized is commonly expressed as a self-typed overload set, using the same two-stubs-plus-implementation shape with the implementation carrying the widened signature. A related tool when the mapping is uniform rather than a fixed set of pairs is a TypeVar.
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.
Mechanically, [union-attr] fires whenever you access an attribute or method that is not present on every member of the union. str has .upper() but bytes does not, so the checker rejects the access on the combined type rather than guessing which member you meant. Pyright reports the same failure as reportAttributeAccessIssue (older pyright releases grouped it under reportGeneralTypeIssues). The remedy is narrowing: an if isinstance(result, str): branch, an assert isinstance(result, str), or a match statement, after which the checker treats the value as the narrowed type inside that scope. That narrowing is exactly the work overloads do for you at the boundary — with a union return every call site pays for it again.
There is a version wrinkle in the union syntax, separate from its semantics. The str | bytes form (PEP 604) is only valid in annotations evaluated on Python 3.10+; on 3.8 and 3.9 you either write Union[str, bytes] from typing or add from __future__ import annotations so the annotation is stored as a string and never evaluated at runtime. Optional[X] is just sugar for X | None, so an -> str | None return is a union return carrying the same narrowing obligation; the Union and Optional Types page covers those narrowing patterns in depth. Choose the union honestly: a lookup that returns a value or None, or a parser that returns a node or an error record irrespective of its input, has no correlation to encode, and wrapping it in overloads would assert a relationship that does not exist. When mypy and pyright disagree about a call’s inferred type, that mismatch is usually the signal that an overload set and the real implementation have drifted apart.
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.
The check mypy performs is specifically about overlap with incompatible returns. Two overloads overlap when some argument could satisfy both parameter lists; overlap alone is fine, and plenty of valid overload sets overlap. It becomes an error only when the overlapping calls would be promised different, non-compatible return types. Here bool is a subclass of int, so a True argument satisfies both (value: int) and (value: bool), yet the first signature promises int and the second str, and both cannot be true for one call. mypy surfaces this as [overload-overlap]; that dedicated error code is relatively recent, so a CI environment pinned to an older mypy may report the same condition under the generic [misc] code instead.
Pyright reports the identical situation as reportOverlappingOverloads, and further distinguishes a partial overlap — where only some arguments collide — from a full one. Three fixes apply, in rough order of preference. First, reorder so the narrower parameter type comes first (put the bool overload above the int overload, so True matches the str-returning signature before the int one). Second, make the return types compatible so the overlap becomes harmless. Third, disambiguate the signatures by parameter count or keyword-only markers so the calls can no longer collide. Reordering is usually correct because it simply aligns declaration order with the subtype relationship, which is how first-match resolution already works.
@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
Most overload problems reduce to a handful of recurring shapes, and each maps to a specific checker diagnostic — so the error code usually tells you which mistake you made before you even reread the code.
- 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. (In a.pyistub file this is expected and allowed; the requirement only applies inside runnable.pymodules.) - Overlapping overloads with incompatible returns:
[overload-overlap]in mypy,reportOverlappingOverloadsin pyright. Put the narrower parameter type first, remembering thatboolis narrower thanintandintis narrower thanfloatunder the numeric tower. - Decorating the implementation too: the final concrete definition must not carry
@overload. If it does, the checker sees three stubs and no implementation and reports[misc]— the same error as forgetting the body entirely. - Assuming runtime dispatch:
@overloaddoes not make Python pick a branch for you. The implementation must inspect its own arguments (isinstance,len, keyword presence) and actually return the type each overload promised; a mismatch between a stub and the body is invisible to the checker and only surfaces at execution. - 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. Calling an @overload-decorated stub directly at runtime actually raises NotImplementedError, which is another reminder that only the implementation is live.
Which mypy and pyright versions do these error codes apply to?
The [overload-overlap] code is emitted by recent mypy; older releases reported the same overlap under [misc], so pin-sensitive CI may differ. [union-attr] and [misc] are long-standing. On pyright, the relevant diagnostics are reportOverlappingOverloads, reportAttributeAccessIssue (formerly folded into reportGeneralTypeIssues), and reportInvalidOverload for malformed overload sets; all are configurable in pyrightconfig.json.
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.