mypy vs pyright on Type Narrowing
mypy and pyright both do flow-based type narrowing — refining a variable’s type along a branch where a condition has been tested — but they diverge on the harder cases, and pyright narrows more aggressively in several of them. If a reveal_type() disagrees between the two checkers, it is almost always one of these patterns: is not None guards, len() on tuples, match statements, walrus assignments, assert, and capturing a narrowed expression into a local. This guide shows where each tool refines the type and where one gives up.
What narrowing is, and why the tools differ
Type narrowing is the process by which a checker refines a declared type (say str | None) to a more specific one (str) inside a block guarded by a test. Both mypy and pyright implement flow analysis for this, but they are independent engines with independent heuristics. The pyright vs mypy split shows up most where narrowing requires tracking more than a single variable’s identity. Use reveal_type(x) (mypy prints Revealed type is; pyright prints Type of "x" is) to see exactly what each infers.
The cases both checkers handle
is not None, isinstance, and assert are the portable core. Write narrowing this way and both agree:
# Python 3.11+, mypy 1.x and pyright — both narrow to str
def render(label: str | None) -> str:
if label is not None:
return label.upper() # str | None -> str in both checkers
assert label is not None # unreachable, but both narrow here too
return label
The walrus operator inside an if test also narrows in both:
# Python 3.11+, mypy 1.x and pyright
def parse_header(raw: str) -> int:
if (value := raw.partition(":")[2].strip()) and value.isdigit():
return int(value) # value: str narrowed by truthiness in both
return 0
Where pyright narrows more aggressively
len() on tuples
pyright narrows a tuple of unknown length to a fixed-length form after a len() check; mypy historically does not refine the tuple type from len().
# Python 3.11+
def midpoint(pts: tuple[int, ...]) -> int:
if len(pts) == 2:
a, b = pts # pyright: pts narrowed to tuple[int, int] -> ok
return (a + b) // 2 # mypy: tuple[int, ...] not narrowed -> [misc] possible
return 0
Under mypy this unpacking may report [misc] (“too many values” is not guaranteed safe), because mypy keeps the variadic tuple[int, ...]. pyright treats len(pts) == 2 as a narrowing guard and refines to tuple[int, int].
match statements on discriminated unions
Both checkers narrow match to a degree, but pyright is more thorough at narrowing on a literal discriminator in a TypedDict or class-pattern tag, including exhaustiveness inference.
# Python 3.11+
from typing import Literal, TypedDict
class TextEvent(TypedDict):
kind: Literal["text"]
body: str
class PingEvent(TypedDict):
kind: Literal["ping"]
def handle(event: TextEvent | PingEvent) -> str:
match event["kind"]:
case "text":
return event["body"] # pyright narrows event to TextEvent reliably
case "ping":
return "pong"
Class-pattern matching (case Point(x=px)) narrows in both, but pyright’s narrowing of the captured sub-patterns tends to be tighter; verify with reveal_type if you depend on it.
Capturing a narrowed expression into a local
Narrowing on an attribute or subscript expression (self.config.timeout is not None) is fragile in mypy: it narrows the expression, but mypy can invalidate that narrowing if any intervening call might mutate the object. pyright tracks narrowed member expressions more persistently.
# Python 3.11+
def use(self) -> int:
if self.config.timeout is not None:
log() # mypy may drop the narrowing after a call
return self.config.timeout # mypy: [return-value] (int | None); pyright: int
return 0
The portable fix that satisfies both engines is to capture into a local first:
# Python 3.11+, mypy 1.x and pyright — both narrow the local
def use(self) -> int:
timeout = self.config.timeout
if timeout is not None:
log()
return timeout # int in both checkers — local is stable
return 0
cast() or assert calls solely to appease one checker without considering whether the narrowing is actually sound for both.
Edge cases
TypeGuard vs TypeIs. Both checkers support TypeGuard (PEP 647) and the narrower TypeIs (PEP 742), but pyright shipped TypeIs narrowing — which also narrows in the negative branch — earlier and more completely. On older mypy versions a TypeIs function may narrow only the positive branch.
Narrowing across comprehensions. Neither tool reliably carries a narrowing from the enclosing scope into a comprehension’s body. Re-test inside the comprehension if you need the refined type there.
Common mistakes
- Relying on mypy narrowing a
len()check. Code that unpackstuple[int, ...]afterlen(...) == 2may pass pyright but fail mypy with[misc]. Annotate the tuple as fixed-length or unpack defensively. - Narrowing an attribute and then calling a method. mypy can reset attribute narrowing after an arbitrary call, surfacing
[union-attr]or[return-value]. Capture into a local to make narrowing portable. - Assuming
matchis exhaustive in both. mypy may not infer exhaustiveness the same way pyright does; add an explicitcase _:or anassert_neverto be safe in both.
FAQ
Which one is “correct” when they disagree? Usually pyright is narrowing something genuinely sound that mypy is being conservative about — but not always. The safest stance is to write code that narrows in both, since CI may run either.