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. The declared type is the widest a variable is allowed to hold; narrowing only ever makes it more specific along a particular control-flow path, and it never changes what the object is at runtime. Both mypy and pyright implement this with flow analysis: each builds a control-flow graph of your function and assigns every variable a type at every program point. Where a branch tests a condition, the checker records a narrower type for the true edge and (sometimes) the false edge; where branches rejoin, it takes the union of the incoming types again. That join step is why a variable narrowed to str inside an if is back to str | None on the line after the block.
Because the two tools are independent engines with independent heuristics, they agree on the common cases and diverge on the harder ones. The pyright vs mypy split shows up most where narrowing requires tracking more than a single local variable’s identity — tuple lengths, member expressions like self.config.timeout, and match subjects. Narrowing is also discarded the moment a checker can no longer prove the binding is unchanged: reassigning the variable resets it to the declared type, and mypy in particular drops a narrowed attribute after an arbitrary function call, because that call could have mutated the object.
The fastest way to see what each tool infers is reveal_type(x). It is a special form both checkers recognise without an import; typing.reveal_type also exists as a real function from Python 3.11 (and in typing_extensions for older runtimes), so the call no longer raises NameError at runtime the way it did on 3.10 and earlier. The printed output differs by tool: mypy emits note: Revealed type is "builtins.str" with fully-qualified names in quotes, while pyright emits information: Type of "x" is "str". When a reveal_type disagrees between the two, you have found a divergence worth encoding defensively rather than depending on whichever checker your CI happens to run.
The cases both checkers handle
The portable core — patterns that narrow identically in mypy and pyright across every supported version — is larger than the divergent set. is None / is not None, isinstance() (including isinstance(x, (A, B)) with a tuple of types), issubclass() on type[...] values, callable(), truthiness (if x:), boolean guards combined with and/or, and assert all belong to it. Write your narrowing with these and you never have to think about which checker CI runs.
Truthiness narrowing has a subtlety both tools get right: if x: on str | None narrows to str in the true branch (only a non-empty string is truthy) but keeps the full str | None in the false branch, because the empty string is also falsy and cannot be removed. On int | None the true branch is int and the false branch stays int | None, since 0 is falsy. Reach for is not None when you specifically mean “not None” rather than “truthy” — the two differ precisely on the empty/zero values.
# Python 3.11+, mypy 1.x and pyright agree on every branch here
def describe(x: str | None) -> str:
if x:
return x.upper() # true branch: str in both
# false branch: x is still str | None (could be "" or None)
return "empty-or-none"
assert narrows for the remainder of the scope, which is the idiomatic way to discharge an Optional at a function boundary before touching it:
def load(config: dict[str, str] | None) -> str:
assert config is not None, "config must be provided"
return config["url"] # config: dict[str, str] in both checkers
is not None, isinstance, and assert are the portable spine. 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
Two forms that look like narrowing but are not in either tool: comparing types with type(x) == int never narrows (only isinstance, and in recent versions the identity form type(x) is int, refines a class), and equality against None written x == None is both non-idiomatic and unreliable for narrowing — prefer the identity form x is None, which PEP 8 also mandates. Getting into the habit of the identity checks keeps the broader mypy vs pyright comparison a non-issue on this axis.
Where pyright narrows more aggressively
When the two tools disagree, pyright almost always narrows further. Three families account for most real-world divergences: refining a variadic tuple after a len() test, narrowing a match subject on a literal discriminator, and keeping a narrowing alive on a member expression (self.config.timeout) across an intervening call. In each case mypy is the more conservative engine — it declines to narrow rather than risk an unsound refinement — so the same source can pass pyright and fail mypy, or vice versa. The matrix below is the shape of the disagreement; the subsections give the exact code and error codes.
len() on tuples
pyright narrows a tuple of unknown length to a fixed-length form after a len() check; mypy, as of the 1.x line, does not refine the tuple type from len() at all.
# 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 error: Too many values to unpack or a [misc] diagnostic, because it keeps the variadic tuple[int, ...] and cannot prove exactly two elements. pyright treats len(pts) == 2 as a narrowing guard and refines to tuple[int, int]; it does the same for >=, <, and comparisons against a Literal, and it narrows TypeVarTuple-style unpacked tuples too. The portable fix is to stop relying on len() for structure: annotate the parameter as tuple[int, int] where the caller can guarantee it, or unpack defensively with a, *rest = pts. This is one of the most common reasons a repository that developed under pyright throws [misc] errors the first time mypy is added in CI.
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"
The subtlety is that match event["kind"] narrows the subject (event["kind"]) to the matched Literal; pyright propagates that back to narrow event itself to the corresponding TypedDict, while mypy narrows the subject but is less reliable at tightening event. Matching on the value directly — match event: with case {"kind": "text"}: mapping patterns, or case TextEvent(): class patterns — narrows more predictably in both.
Exhaustiveness is the other divergence. To make a match provably total in both tools, add a wildcard that funnels the impossible case into assert_never (from typing on 3.11+, else typing_extensions):
# Python 3.11+
from typing import assert_never
def area(shape: Circle | Square) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r * r
case Square(side=s):
return s * s
case _ as unreachable:
assert_never(unreachable) # both flag [arg-type] if a subtype is added
If someone later adds a Triangle member to the union, the _ branch is no longer Never and both mypy and pyright report an error at the assert_never call — turning a silent missing-case bug into a compile-time failure. Without it, mypy may emit [return] (“Missing return statement”) while pyright reports a possible fall-through, and the messages won’t line up.
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 invalidates that narrowing if any intervening call might mutate the object, because it cannot prove the attribute still holds the same value. 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 — a plain local variable cannot be mutated by an unrelated call, so both tools keep the narrowing:
# 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
Declaring the attribute Final is the other portable option: timeout: Final[int | None] tells both checkers the attribute can never be reassigned, so narrowing on self.timeout survives across calls even in mypy. Capture-into-a-local is the more general habit, though, since it also works for subscripts and function results.
cast() or assert calls solely to appease one checker without considering whether the narrowing is actually sound for both.
Edge cases
Beyond the everyday guards, a handful of features have narrowing semantics that are easy to get wrong and where the two tools have shipped support at different times.
TypeGuard vs TypeIs. A user-defined type guard is a function returning bool annotated with a special return type. TypeGuard[X] (PEP 647, typing from 3.10, else typing_extensions) narrows the argument to X in the positive branch only — the negative branch is left at the declared type — and the guarded type X need not be a subtype of the parameter. TypeIs[X] (PEP 742, typing from 3.13, else typing_extensions) narrows both branches: the true branch to X, and the false branch to the declared type with X subtracted. TypeIs requires X to be a consistent subtype of the parameter type, which is what makes the two-sided narrowing sound.
from typing_extensions import TypeGuard, TypeIs # or typing on 3.13+
def is_str_list(v: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in v)
def is_str(v: str | int) -> TypeIs[str]:
return isinstance(v, str)
def f(v: str | int) -> None:
if is_str(v):
reveal_type(v) # str — both branches narrow with TypeIs
else:
reveal_type(v) # int — only TypeIs narrows the negative branch
Both checkers support both features today, but pyright shipped TypeIs narrowing earlier and more completely; on older mypy releases a TypeIs function may narrow only the positive branch (behaving like TypeGuard), so check your mypy version if you depend on the negative-branch refinement.
Narrowing across comprehensions and closures. Neither tool carries a narrowing from the enclosing scope into a comprehension body, a nested def, or a lambda, because the checker cannot prove the variable is still narrowed by the time the closure actually runs — it could be reassigned in between.
def process(items: list[str] | None) -> list[str]:
if items is None:
return []
out = [s.upper() for s in items] # ok: same scope, items is list[str]
fn = lambda: len(items) # inside lambda, items is list[str] | None again
return out
Re-test inside the comprehension or capture the narrowed value into a parameter or default if you need the refined type there. Reassigning a variable inside a branch also drops any narrowing from before it, so keep the guarded read and any reassignment on separate variables when it matters.
Common mistakes
Most portability failures are variations on the same theme: relying on a refinement that one checker performs and the other does not, or letting a narrowed type quietly reset. Reading the mistakes as a ladder of increasingly portable habits — from the fragile pattern at the bottom to the two-checker-safe one at the top — makes the fixes easy to remember. The single diagnostic that settles any dispute is reveal_type(): run it under both checkers at the exact line that misbehaves, and the first place their output disagrees is the pattern to rewrite. Because CI may run either tool, and a future teammate may switch, coding to the portable subset is cheaper than tracking which checker is authoritative this quarter.
- Relying on mypy narrowing a
len()check. Code that unpackstuple[int, ...]afterlen(...) == 2may pass pyright but fail mypy with[misc]or “Too many values to unpack”. Annotate the tuple as a fixed-lengthtuple[int, int], or unpack defensively witha, *rest = pts. - Narrowing an attribute and then calling a method. mypy resets attribute narrowing after an arbitrary call, surfacing
[union-attr]on the next access or[return-value]on the return. Capture into a local, or mark the attributeFinal, to make the narrowing portable. - Assuming
matchis exhaustive in both. mypy may not infer exhaustiveness the same way pyright does; add an explicitcase _:withassert_never(...)so both tools error when a new union member is introduced instead of silently falling through. - Using
type(x) == Corx == Noneto narrow.type(x) == Cnever narrows in either checker (useisinstance), andx == Noneis unreliable for narrowing and violates PEP 8 — always writex is None/x is not None. - Expecting narrowing to survive reassignment or cross into a closure. Rebinding the variable, or reading it inside a nested
def/lambda/comprehension, drops the refined type back to the declaration in both tools.
The single most portable recipe is to capture the narrowed expression into a local and, where the value is a class or instance attribute, mark it Final so neither checker assumes an intervening call could have mutated it. The snippet below narrows in both mypy and pyright and fails in neither:
from typing import Final
class Cache:
value: Final[int | None]
def __init__(self, v: int | None) -> None:
self.value = v
def double(self) -> int:
v = self.value # capture once
if v is None: # narrows the local, not the attribute
raise ValueError("empty")
# reveal_type(v) -> int in both mypy and pyright
return v * 2
Without the local and the Final, mypy resets self.value to int | None after any method call between the guard and the use, emitting error: Unsupported operand types for * ("None" and "int") [operator]; pyright is more forgiving but still cannot prove safety once the attribute is mutable. Capturing into v sidesteps the whole class of divergence. When you must branch on a union tag, pair the capture with assert_never in the case _: arm — see TypeGuard and Type Narrowing and the pyright vs mypy Comparison for the exhaustiveness patterns both tools honour.
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.