@overload Resolution: mypy vs pyright
Both mypy and pyright resolve @overload by trying each signature top to bottom and taking the
first match, so order matters. They diverge on overlap detection (mypy flags unsafe overlaps with
[overload-overlap]; pyright reports them as a separate diagnostic class), on how strictly the
implementation must be compatible with the overloads, and on argument-evaluation order for ambiguous
calls. Order overloads most-specific first and keep the implementation signature broad.
@overload lets one function present several typed signatures to the checker while sharing a single
runtime implementation. The resolution algorithm — try each overload in source order, pick the first
whose parameters match — is shared by mypy and pyright, but the diagnostics they emit when
overloads overlap or the implementation is incompatible differ enough to matter in CI. This guide,
part of Advanced Typing Patterns & Generics, puts the two side
by side. For the basics of declaring overloads, see
Function Overloading.
Versions under test
The behaviour below was observed on mypy 1.10+ and pyright 1.1.360+. Both checkers now
implement the same overload call-evaluation procedure — the one written into the official Python
typing specification — so for concrete arguments they resolve identically. What still differs is the
diagnostics: the exact error codes, the wording, and a handful of tie-breaks when an argument is
Any. Print your versions with mypy --version and pyright --version, and pin both in CI so that
a checker upgrade cannot silently change which overload a call selects.
Version history matters when you support a range of interpreters. typing.overload has existed since
Python 3.5.3, so you rarely need typing_extensions.overload; the extension exists mainly to
back-port the Python 3.11 runtime registry — typing.get_overloads() and typing.clear_overloads()
— which lets tooling introspect the registered stubs at runtime. mypy’s dedicated
[overload-overlap] error code and pyright’s spec-aligned argument-expansion step both stabilised in
the versions above; older releases produced noisier, less consistent overlap warnings. Pin both per
the pyright vs mypy comparison.
reveal_type is understood by both checkers with no import (it is a special form the analyzers
recognise); if you want the call to survive at runtime as well, typing.reveal_type has existed as a
real function since Python 3.11. When you do suppress an overload diagnostic, turn on
warn_unused_ignores in mypy and reportUnnecessaryTypeIgnoreComment in pyright so that a stale
# type: ignore fails CI the moment the underlying overlap is fixed — suppressions around overloads
have a habit of outliving the bug they hid.
Shared rule: first match wins, so order matters
Both checkers evaluate overloads in source order and stop at the first signature whose parameters are compatible with the call. Resolution runs in two conceptual passes: first an arity filter discards overloads that cannot accept the number of arguments supplied, then the survivors are checked in order and the first whose argument types are assignable wins. A more general overload placed before a specific one therefore shadows it — the general one matches first and the specific one is never consulted.
# Python 3.11+, mypy 1.10 AND pyright 1.1.360 — identical resolution
from typing import overload
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: str) -> str: ...
def parse(value: int | str) -> int | str:
return value
reveal_type(parse(3)) # both: int
reveal_type(parse("x")) # both: str
Order most-specific first. bool before int matters because bool is an int subtype: an int
overload listed first would match a bool call, and you would never reach the bool overload.
# Python 3.11+
from typing import overload
@overload
def label(x: bool) -> str: ... # specific: bool first
@overload
def label(x: int) -> int: ... # general: int second
def label(x: int) -> str | int:
return "flag" if isinstance(x, bool) else x
reveal_type(label(True)) # str — the bool overload is reached
reveal_type(label(5)) # int
Reverse those two and label(True) resolves to int, because bool is a subclass of int: the
int overload matches a bool argument first and the bool overload becomes dead code. pyright
emits “overload will never be used” for the shadowed signature; mypy reports it under
[overload-overlap]. Keep the subtype relationships Python bakes in front of mind — bool is a
subtype of int, int is accepted where float is expected under the numeric tower (though it is
not literally a subclass), and every class is a subtype of object, so an object overload placed
first swallows everything.
The arity filter that runs before type matching is more subtle than a plain parameter count. An
overload with a default value or *args accepts a range of argument counts, so it survives the
filter for any count in that range; keyword-only parameters (those after a bare *) are matched by
name rather than position. When two surviving overloads both accept the call, source order breaks the
tie — which is the entire reason ordering is load-bearing.
A canonical production case is dispatching on a Literal argument, exactly how the typeshed stub for
the built-in open works: the return type depends on the mode string.
# Python 3.11+
from typing import overload, Literal, IO
@overload
def open_stream(path: str, mode: Literal["r"]) -> IO[str]: ...
@overload
def open_stream(path: str, mode: Literal["rb"]) -> IO[bytes]: ...
def open_stream(path: str, mode: str) -> IO[str] | IO[bytes]:
...
reveal_type(open_stream("a.txt", "r")) # IO[str]
reveal_type(open_stream("a.bin", "rb")) # IO[bytes]
Because the overloads are distinguished only by a Literal, both checkers require the argument to be
a literal known at the call site. Pass a plain str variable and neither Literal overload matches,
so a broad fallback — def open_stream(path: str, mode: str) -> IO[str] | IO[bytes] as an extra
overload — is needed or the call is a [call-overload] error. This literal-dispatch pattern is why
both careful ordering and a catch-all overload matter in real stubs.
Divergence 1: overlap detection diagnostics
When two overloads overlap such that one could match where a different return type is expected, mypy
reports [overload-overlap] (the message “Overloaded function signatures N and M overlap with
incompatible return types”). pyright reports the same hazard under its own
reportOverlappingOverload diagnostic.
# Python 3.11+
from typing import overload
@overload
def handle(value: int) -> str: ...
@overload
def handle(value: bool) -> int: ... # bool is a subtype of int -> unreachable / overlap
def handle(value: int) -> str | int:
return ""
# mypy 1.10: error: Overloaded function signatures 1 and 2 overlap with incompatible return types [overload-overlap]
# pyright 1.1: error: Overload 2 will never be used because its parameters overlap overload 1 — reportOverlappingOverload
The key word is incompatible. Two overloads overlap whenever some argument could satisfy both; that is only an error when the overlapping calls would return types the checker cannot reconcile — then it has no sound answer to give. Overlap with the same (or a compatible) return type is harmless and neither tool complains:
# Python 3.11+
from typing import overload
@overload
def norm(x: bool) -> float: ...
@overload
def norm(x: int) -> float: ... # overlaps overload 1 but same return -> no error
def norm(x: int) -> float:
return float(x)
Fix an unsafe overlap by reordering (put the subtype first so it is reached) or by widening the
earlier return so the two are compatible. To suppress, mypy targets overload-overlap; pyright
targets reportOverlappingOverload. A # type: ignore[overload-overlap] does nothing for pyright,
and a # pyright: ignore[reportOverlappingOverload] does nothing for mypy — the identifiers are not
interchangeable. (Historically mypy produced false overlap positives on overloaded descriptors such
as @property getters; modern versions handle descriptor overloads correctly, so an overlap warning
on current mypy is worth trusting rather than blanket-suppressing.)
Divergence 2: implementation compatibility
The implementation is the single runtime function; the overloads are promises the checker must be
able to keep. So the implementation signature has to be a supertype of every overload: its
parameter list must accept every argument any overload allows (usually the union), and each
overload’s return type must be assignable to the implementation’s declared return. mypy verifies both
directions and reports [misc] (“Overloaded function implementation does not accept all possible
arguments”) when the parameters do not line up; pyright phrases the same failure as “Implementation
is not consistent with overload N.”
# Python 3.11+
from typing import overload
@overload
def first(value: int) -> int: ...
@overload
def first(value: str) -> str: ...
def first(value: int) -> int: # impl omits str -> incompatible with overload 2
return value
# mypy 1.10: error: Overloaded function implementation does not accept all possible arguments of signature 2 [misc]
# pyright 1.1: error: Implementation is not consistent with overload 2
Keep the implementation signature broad — typically the union of all overload inputs — so it accepts
every case the overloads promise. Two related rules trip people up. First, the implementation body
itself is not re-checked once per overload; the checker trusts the overload signatures and only
verifies the implementation against them, so an internal isinstance ladder is your responsibility.
Second, the implementation’s return annotation should be broad enough (often the union of every
overload return, or object) that each overload return is assignable to it — otherwise mypy reports
an incompatible-return error even though the parameters line up. In a .pyi stub the implementation
is omitted entirely; only the decorated signatures appear.
This return direction bites hardest in --strict mode. If you annotate the implementation -> int
but an overload promises -> str, mypy flags an incompatible return even when every parameter is
fine. The conventional fixes are to type the implementation return as the union of all overload
returns, or as object when the branches genuinely produce unrelated types and you rely on the
overloads to narrow for callers. Crucially, the implementation body is checked against its own
signature, not against the overloads — so a # type: ignore placed inside it silences a local
problem and never leaks out to the promises callers see.
Divergence 3: ambiguous matches and argument types
Ambiguity arises in two distinct ways, and the checkers handle them per the spec. First, a union
argument: the checkers expand int | str into its members, resolve each against the overloads, and
combine the per-member results — so a call with an int | str argument yields int | str, and if
any member matches no overload the whole call is an error. Second, an Any argument: if more
than one overload could match and they return different types, the spec says the result collapses to
Any rather than guessing; mypy follows this, while some older pyright builds returned the first
overload’s type. Always confirm with reveal_type.
# Python 3.11+
from typing import overload, Any
@overload
def widen(value: int) -> int: ...
@overload
def widen(value: str) -> str: ...
def widen(value: int | str) -> int | str:
return value
u: int | str
reveal_type(widen(u)) # both: int | str — union expanded member by member
x: Any
reveal_type(widen(x))
# mypy: int | str (or Any, depending on settings)
# pyright: int (first overload) — confirm in your toolchain
Union expansion interacts with subtyping in a way that occasionally surprises. Given overloads on
int and str, a call with bool | str expands to bool and str; bool resolves through the
int overload because it is a subtype, str through its own, and the combined result is int | str.
But if the overloads covered int and bytes and you called with int | str, the str member
matches no overload, and the whole call fails — [call-overload] in mypy, reportCallIssue in
pyright. The checkers never silently drop an unmatched union member; every member must find a home.
Common mistakes
Most overload errors reduce to a handful of recurring shapes. Here are the ones that surface most often in code review, each paired with the diagnostic that catches it:
- General overload before specific: A broad signature placed first shadows the specific one, so it “never matches”. Order most-specific first; pyright will warn the shadowed overload is unused, and mypy flags the overlap.
- Narrow implementation signature: If the implementation does not accept every overload’s inputs,
mypy reports
[misc]. Type the implementation as the union of all overload parameters. - Suppressing the wrong code:
overload-overlap(mypy) andreportOverlappingOverload(pyright) are different identifiers; suppress each on its own checker. - Single
@overload: One overload plus an implementation is rejected — overloads require at least two decorated signatures. mypy reports “single overload definition, multiple required” as[misc]; if you find yourself writing one overload, you want a plain annotation or a union, not@overload. - Missing implementation: In a
.pyfile the decorated stubs need a concrete implementation after them, or mypy reports[no-overload-impl]. In a.pyistub the reverse holds — the implementation is forbidden, and only the@overloadsignatures appear. - Decorator order with
@staticmethod/@classmethod: Apply@overloadbeneath@staticmethodon each stub and repeat both decorators on the implementation, or the checker mis-reads the descriptor and the overloads fail to register.
FAQ
Why does pyright say an overload “will never be used”? An earlier overload already matches every call the later one would, usually because the earlier parameter type is a supertype. Reorder so the more specific overload comes first.
Do mypy and pyright ever pick different overloads for the same call?
For concrete arguments, no — first-match is deterministic and shared. For Any or union arguments
the result can differ; confirm with reveal_type and design overloads so the choice is unambiguous.