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

Overload correlation vs union collapse With overloads a str input yields a str result and a bytes input yields a bytes result. With a union return both inputs collapse to str-or-bytes and the caller must narrow. @overload str in bytes in str out bytes out union return str in bytes in str | bytes
Overloads keep each input correlated to its output; a union return collapses both into one type the caller must narrow.

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.

Runtime vs static analysis The @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 @overload with 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, reportOverlappingOverloads in 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 | bytes union 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.

Back to Function Overloading