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.

Narrowing agreement between mypy and pyright Both checkers narrow on is-not-None and assert; pyright narrows further on len of tuples, match patterns, and captured locals. Same source, two flow engines Both narrow x is not None isinstance(x, T) assert x is not None if (y := f()) is not None portable narrowing pyright narrows further len(t) == 2 on tuples match on TypedDict tag captured local = x.attr discriminated unions verify with reveal_type
The portable subset is large; the divergences cluster around tuples, match, and captured expressions.

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.

Flow of a type through a narrowing guard A declared union enters a guard, is refined on the true branch, then widens back to the union at the join point after the block. declared str | None if x is not None flow guard true branch x: str false branch x: None join str | None
Narrowing refines the type per branch and unions it back at the join point after the block.

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.

Portable narrowing guards and their results A stack of guard forms on the left maps to the narrowed type each yields identically in mypy and pyright. guard form narrowed result (both tools) x is not None T | None → T isinstance(x, (A, B)) object → A | B if x: (str | None) true → str, false → str | None callable(x) object → Callable[..., Any] assert x is not None rest of scope → T if (y := f()) is not None y: T inside branch
Each guard on the left yields the same narrowed type in both checkers — the safe subset to build on.

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.

mypy vs pyright on three divergent narrowing patterns A matrix comparing len-on-tuple, match on a literal tag, and captured member expressions across mypy and pyright. mypy pyright len(t) == 2 stays tuple[int, ...] tuple[int, int] match tag tag only, sometimes full TypedDict x.attr + call narrowing dropped narrowing kept
The same three rows where mypy stays conservative and pyright refines further — the divergence at a glance.

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.

Runtime vs static analysis Narrowing is purely a compile-time inference — at runtime the object is whatever it is, regardless of which checker narrowed it. When pyright accepts code mypy rejects, the runtime behavior is identical; only the static guarantee differs. Don't add runtime 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 versus TypeIs branch narrowing TypeGuard refines only the true branch while TypeIs refines both the true and false branches of the same guard. TypeGuard (PEP 647) TypeIs (PEP 742) v: str | int if guard(v) v: str else v: str | int not narrowed v: str | int if guard(v) v: str else v: int also narrowed
TypeGuard refines only the positive branch; TypeIs subtracts the type on the negative branch too.

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.

Portability ladder for narrowing habits Climbing from equality checks and reused attributes up to isinstance guards, captured locals, and assert_never makes narrowing agree across both checkers. less portable → more portable x == None / type(x) == C — never narrows in either tool len(t) == 2 on a variadic tuple — pyright only narrow x.attr then call a method — resets in mypy isinstance / is None + capture into a Final local case _: assert_never(x) — both error on a new member
Climb the ladder: replace equality checks and reused attributes with isinstance guards, captured Final locals, and exhaustiveness assertions that both checkers honour.
  • Relying on mypy narrowing a len() check. Code that unpacks tuple[int, ...] after len(...) == 2 may pass pyright but fail mypy with [misc] or “Too many values to unpack”. Annotate the tuple as a fixed-length tuple[int, int], or unpack defensively with a, *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 attribute Final, to make the narrowing portable.
  • Assuming match is exhaustive in both. mypy may not infer exhaustiveness the same way pyright does; add an explicit case _: with assert_never(...) so both tools error when a new union member is introduced instead of silently falling through.
  • Using type(x) == C or x == None to narrow. type(x) == C never narrows in either checker (use isinstance), and x == None is unreliable for narrowing and violates PEP 8 — always write x 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.

Back to pyright vs mypy Comparison