@overload Resolution: mypy vs pyright

TL;DR

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.

First-match overload resolution A call is matched against overloads in source order; the first matching signature wins and determines the return type. call: parse(3) 1. parse(x: int) -> int 2. parse(x: str) -> str 3. impl (broad) first match -> int
Overloads are tried top to bottom; the first matching signature determines the return type.

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.

Overload support timeline Milestones from typing.overload in Python 3.5.3 through PEP 484 semantics, the Python 3.11 runtime registry, and today's spec-aligned diagnostics. Py 3.5.3 typing.overload PEP 484 first-match rule Py 3.11 get_overloads() current spec-aligned
overload has lived in typing since 3.5.3; runtime introspection arrived in 3.11; today's checkers align on the spec.

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.

Overload order: general-first shadows, specific-first works With the int overload first a bool call matches int and the bool overload is dead; putting bool first resolves a bool argument correctly. int before bool (bug) 1. f(x: int) -> int 2. f(x: bool) -> str dead code f(True) resolves to overload 1 -> int bool before int (correct) 1. f(x: bool) -> str 2. f(x: int) -> int f(True) resolves to overload 1 -> str
Because bool is a subtype of int, only the specific-first ordering lets a bool argument reach its intended overload.

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

Overlap diagnostics: mypy vs pyright The same overlapping-overload hazard is reported by mypy as overload-overlap and by pyright as reportOverlappingOverload with different wording. mypy pyright error id [overload-overlap] reportOverlappingOverload message signatures N and M overlap, incompatible returns overload N will never be used
Same hazard, two identifiers: suppress each on its own checker.

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
Implementation must be a supertype of the overloads Each overload's parameter type flows into a single implementation whose parameter is the union of them all. overload 1: int overload 2: str overload 3: bytes implementation def f(x: int | str | bytes) return type: a supertype
The implementation parameter must contain the union of every overload input, or mypy raises [misc].

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
Resolving ambiguous overload calls A union argument is expanded member by member; an Any argument that matches several overloads with different returns collapses to Any. argument is Any? no yes expand members, first match each mypy = pyright (deterministic) several matches, different returns? decide below yes no result: Any that return
Union arguments resolve deterministically; an ambiguous Any argument collapses to Any under the spec.

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.

Runtime vs static analysis At runtime there is exactly one function — the implementation. The `@overload`-decorated stubs are discarded when the module loads (calling one raises `NotImplementedError`). Overload *resolution* happens only in the type checker; Python itself dispatches on the single implementation body regardless of which overload the checker matched.

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) and reportOverlappingOverload (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 .py file the decorated stubs need a concrete implementation after them, or mypy reports [no-overload-impl]. In a .pyi stub the reverse holds — the implementation is forbidden, and only the @overload signatures appear.
  • Decorator order with @staticmethod/@classmethod: Apply @overload beneath @staticmethod on each stub and repeat both decorators on the implementation, or the checker mis-reads the descriptor and the overloads fail to register.
Overload pitfalls to avoid Five recurring overload mistakes: general overload first, a narrow implementation, the wrong suppression code, a single overload, and a missing implementation. x general overload before the specific one (shadowed) x implementation too narrow for all overload inputs x suppressing the wrong checker's error code x only one @overload (two are required) x missing implementation in a .py module
Five overload mistakes the checkers catch — each is avoidable by ordering, widening, and counting your stubs.

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.

Back to Function Overloading