mypy vs pyright on TypeGuard and TypeIs

TL;DR

mypy and pyright agree on the core PEP 647 / PEP 742 rules: TypeGuard narrows only the positive branch, TypeIs narrows both. They diverge on details — pyright is stricter about generic guards and emits report-style messages, while mypy uses named error codes like [narrowed-type-not-subtype], handles the assert-plus-guard interaction slightly differently, and historically lagged pyright on TypeIs support. Pin mypy ≥ 1.10 and a recent pyright ≥ 1.1.360 for consistent behaviour.

User-defined narrowing arrived in two PEPs: PEP 647 (TypeGuard, Python 3.10) and PEP 742 (TypeIs, Python 3.13). Both mypy and pyright implement both, but because the PEPs leave some behaviour to the implementation — and because the two checkers have independent narrowing engines — the same guard can produce different reveal_type output. This guide puts the concrete divergences side by side, with the versions they were observed on. For the underlying mechanics, start with the TypeGuard and TypeIs overview.

mypy vs pyright narrowing comparison Both checkers narrow the true branch identically, but report different diagnostics for an unsound TypeIs guard. mypy 1.x if branch -> narrowed else branch -> TypeIs only [narrowed-type-not-subtype] named error codes pyright 1.1.x if branch -> narrowed else branch -> TypeIs only reportGeneralTypeIssues prose report messages
Identical narrowing semantics, different diagnostics and stricter generic handling in pyright.

Versions under test

The behaviour below was observed on mypy 1.10+ and pyright 1.1.360+, both targeting Python 3.13 so that TypeIs is available natively. On earlier toolchains, import TypeIs from typing_extensions. Pin both in CI; the rest of the pyright vs mypy comparison covers configuration parity.

Availability timeline for TypeGuard and TypeIs PEP 647 TypeGuard arrived in Python 3.10 and PEP 742 TypeIs in Python 3.13; mypy 1.10 and pyright 1.1.360 are the baselines for consistent TypeIs behaviour. Availability timeline — pin both checkers in CI Python 3.10 PEP 647 · TypeGuard Python 3.13 PEP 742 · TypeIs mypy 1.10+ native TypeIs pyright 1.1.360+ consistent results
Below these versions, import the forms from typing_extensions; above them, mypy and pyright agree on the common cases.

Version pinning is not optional here, because the divergences below are version-specific: TypeIs narrowing on mypy earlier than 1.10 differs from any pyright release, and a floating pyright picked up by an editor may report differently from the CI pin. Record exact versions in requirements/lock files and, ideally, run a small reveal_type smoke test in CI so a checker upgrade that changes inference is caught deliberately rather than as a surprise diff. Both tools read the same source and the same python_version/pythonVersion; set that target to 3.13 when you depend on native TypeIs so neither checker silently treats the import as unavailable. TypeGuard has the wider floor (Python 3.10, or typing_extensions on 3.9), while TypeIs needs 3.13 or the backport, so a mixed codebase may legitimately use both forms under one interpreter.

If you support several Python versions, a CI matrix that runs mypy and pyright against each target (see matrix testing mypy across Python versions) is the reliable way to catch version-specific narrowing differences before users do. Set each job’s python_version/pythonVersion to the row it tests rather than the interpreter running the checker, because the checker cross-analyses for the configured target: a job configured for 3.12 must treat from typing import TypeIs as unavailable and expect the typing_extensions import, whereas a 3.13 job accepts the native form. Keeping the checker versions themselves pinned across the matrix means any divergence you see is attributable to the Python target, not to a checker upgrade.

Agreement: the positive branch and TypeIs negative branch

Start with what is identical, so you know what not to worry about. Both checkers narrow the if branch of a TypeGuard to the guarded type, and both narrow both branches of a TypeIs.

Both checkers agree on the branches of a TypeIs A value typed int or str passes through is_str, and both mypy and pyright infer str in the if branch and int in the else branch. value: int | str if is_str(value) TypeIs[str] True False if -> str mypy = pyright else -> int mypy = pyright
On a sound TypeIs, both checkers infer the same type in each branch — this is the baseline the divergences below depart from.
# Python 3.13+, mypy 1.10 AND pyright 1.1.360 — identical results
from typing import TypeIs

def is_str(value: int | str) -> TypeIs[str]:
    return isinstance(value, str)

def handle(value: int | str) -> None:
    if is_str(value):
        reveal_type(value)   # both: str
    else:
        reveal_type(value)   # both: int

This agreement is not a coincidence: the positive-branch narrowing of TypeGuard and the two-branch narrowing of TypeIs are spelled out precisely in PEP 647 and PEP 742, so both engines implement the same rule. For a TypeIs, the else branch is the set-difference of the input and the guarded type (int | str minus str is int); for a TypeGuard, both checkers narrow only the if branch and leave the else at the declared type. As long as the guard is sound and non-generic, you can treat mypy and pyright as interchangeable and will get identical reveal_type output. The divergences that follow all live at the edges — unsound guards, generic binding, assert-driven unreachability, and method receivers — so knowing this common core tells you exactly which cases are safe to ignore.

Divergence 1: the unsound-TypeIs diagnostic

PEP 742 requires the guarded type to be consistent with the parameter type. Both checkers reject a violation, but the message differs — which matters when you grep CI logs.

Same unsound guard, two different diagnostics A guard declaring TypeIs of str on an int parameter is rejected by both checkers, but mypy emits the named code narrowed-type-not-subtype while pyright emits reportGeneralTypeIssues. def bad(value: int) -> TypeIs[str] (unsound) mypy [narrowed-type-not-subtype] pyright reportGeneralTypeIssues
Both reject the unsound guard; only the diagnostic identifier differs, which is what your suppression comment and log grep must match.
# Python 3.13+
from typing import TypeIs

def bad(value: int) -> TypeIs[str]:   # str is not consistent with int
    return False
# mypy 1.10:   error: Narrowed type "str" is not a subtype of input type "int"  [narrowed-type-not-subtype]
# pyright 1.1: error: Return type of TypeIs must be assignable to the first parameter — reportGeneralTypeIssues

mypy gives you a stable, suppressible error code; pyright gives a descriptive message under reportGeneralTypeIssues. To suppress, you target narrowed-type-not-subtype in mypy versus reportGeneralTypeIssues in pyright. The agreement here is that both reject the guard — the soundness rule is shared — so this is a divergence of reporting, not of semantics. It bites in two practical ways. First, log parsing: a CI step that greps for narrowed-type-not-subtype finds nothing in pyright output, and one that greps pyright’s prose finds nothing in mypy’s. Second, suppression is not portable: # type: ignore[narrowed-type-not-subtype] is inert for pyright, and # pyright: ignore[reportGeneralTypeIssues] is inert for mypy, so a file checked by both needs both comments (and, with mypy’s warn_unused_ignores, an unused mypy ignore becomes its own error). Note also that reportGeneralTypeIssues is a broad pyright bucket covering many issues, so suppressing it here may mask unrelated problems on the same line — prefer fixing the guard to switch to TypeGuard or correct the guarded type.

Divergence 2: generic guards and inference

pyright tends to retain more precise types when a guard is generic, while mypy is sometimes more conservative and falls back to the declared input type. Consider a generic TypeIs:

Generic guard inference across checkers and versions A generic is_two guard applied to list of int yields the widened list int on older mypy but tuple int int on mypy 1.10 and on pyright. is_two(items: list[int]) -> TypeIs[tuple[T, T]] mypy < 1.10 list[int] (widened) mypy 1.10+ tuple[int, int] pyright 1.1.360+ tuple[int, int]
Current mypy and pyright bind the generic guard to tuple[int, int]; only older mypy widens back to the declared input.
# Python 3.13+
from typing import TypeIs, TypeVar

T = TypeVar("T")

def is_two(value: list[T]) -> TypeIs[tuple[T, T]]:  # contrived: illustrates generic binding
    return False

def use(items: list[int]) -> None:
    if is_two(items):
        reveal_type(items)
        # pyright: tuple[int, int]
        # mypy:    tuple[int, int]  (1.10+) — earlier mypy widened to list[int]

The practical takeaway: on older mypy you may see the binding lost, so confirm with reveal_type in your actual toolchain rather than assuming parity. Generic guards over TypeVars are the most common source of checker disagreement. The underlying question is how far each engine propagates the TypeVar solution from the parameter (list[T] bound to list[int], so T = int) into the guarded type (tuple[T, T] becoming tuple[int, int]). Current versions of both do this; the historical mypy behaviour dropped the binding and fell back to the declared input type, which is exactly the kind of silent, precision-losing difference that a pinned CI version prevents. The divergence grows with guard complexity — nested generics, multiple type parameters, and constrained TypeVars are where the two engines are most likely to differ — so for anything beyond a single straightforward binding, assert the expected type with reveal_type under both tools rather than trusting that they agree. When they genuinely disagree, prefer the more precise (usually pyright’s) result and rewrite the guard so mypy reaches it too, for example by making the binding explicit.

Divergence 3: interaction with assert

Both checkers narrow after assert is_str(value), but they differ on unreachability. After an assert on a TypeGuard whose positive type is empty in context, pyright is quicker to mark following code unreachable, while mypy may keep analysing it.

Assert-driven unreachability: pyright versus mypy Asserting an impossible narrowing on an int value moves pyright to a Never, unreachable state, while mypy narrows to str and keeps analysing. value: int assert is_str(value) impossible narrowing pyright: Never rest unreachable mypy: str keeps analysing
On an impossible assert, pyright transitions to Never and marks the rest unreachable; mypy narrows to the guarded type and continues.
# Python 3.13+
from typing import TypeIs

def is_str(value: int | str) -> TypeIs[str]:
    return isinstance(value, str)

def f(value: int) -> None:        # note: value is int, not int | str
    assert is_str(value)          # asserts an impossible narrowing
    reveal_type(value)
    # pyright: Never (treats the rest as unreachable)
    # mypy:    str  (narrows to the guarded type without proving unreachability)

The difference is about proving impossibility. pyright reasons that is_str cannot succeed for an int, so the code after the assert is unreachable and the variable is Never; mypy trusts the guard and narrows value to the declared guarded type str without deciding the branch is dead. This matters in two ways. It changes coverage of subsequent code — pyright may report following statements as unreachable (and, with reportUnreachable or an editor hint, flag them), while mypy type-checks them normally; and it changes the reported type at reveal_type. The deeper signal is that asserting an impossible guard is itself a code smell — it usually means the parameter type is narrower than you thought — so rather than depending on either behaviour, fix the guard or the input type. mypy’s --warn-unreachable closes part of the gap by flagging genuinely dead branches, but it will not, on its own, make mypy agree with pyright on this constructed case.

Divergence 4: narrowing self vs the first positional

A guard narrows its first positional parameter. On a method, that parameter is self. pyright and mypy both bind the guard to self in that case, but pyright surfaces a clearer warning that the guard is narrowing the receiver, whereas mypy silently accepts it. Use a free function — both checkers narrow the passed argument consistently — rather than a method-based guard.

Method guards narrow self in both checkers A guard defined as a method narrows self in both mypy and pyright, but pyright warns while mypy is silent; the fix is a free function that narrows the passed argument. method guard def is_ready(self) -> TypeIs[Ready] mypy: narrows self silently accepts pyright: narrows self surfaces a warning fix: a free function narrowing the argument
Both checkers bind a method guard to self; only pyright warns. A free function makes the narrowed argument explicit and behaves identically in both.

Concretely, a method guard narrows the receiver, which is almost never the intent:

# Python 3.13+, mypy 1.10 / pyright 1.1.360
from typing import TypeIs

class Widget:
    def is_ready(self) -> TypeIs["ReadyWidget"]:   # narrows self, not an argument
        return isinstance(self, ReadyWidget)

# Prefer a free function so the narrowed value is the passed argument:
def is_ready(w: Widget) -> TypeIs["ReadyWidget"]:
    return isinstance(w, ReadyWidget)

With the method form, if widget.is_ready(): tries to narrow widget (the receiver) — pyright flags that the guard is narrowing self, while mypy accepts it silently, so the same code is “clean” on one checker and “warned” on the other. The free-function form narrows w at the call site (if is_ready(widget):), which both checkers treat identically, and it reads more naturally because the value being tested is an explicit argument. A @staticmethod taking the value works too, since then the first positional parameter is the value rather than self. When you must keep a predicate on the class, make it return plain bool and pair it with a separate free-function guard for the narrowing.

Common mistakes

The three recurring cross-checker mistakes are summarised below, then expanded.

Three common mypy-versus-pyright guard mistakes Assuming TypeGuard narrows the else branch, suppressing the wrong diagnostic identifier, and running mismatched checker versions in CI. TypeGuard else? neither checker narrows it use TypeIs wrong suppression mypy code is not pyright category add both comments mismatched CI mypy < 1.10 differs from pyright pin 1.10+ / 1.1.360+
Three traps when a codebase is checked by both tools: else-branch assumptions, suppression identifiers, and version drift.
  • Assuming TypeGuard narrows the else branch on one checker: Neither does. TypeGuard is one-way in both mypy and pyright. If you need the negative branch, switch to TypeIs (when the guarded type is a subtype of the input), and confirm with reveal_type on both tools.
  • Suppressing the wrong identifier: A # type: ignore[narrowed-type-not-subtype] silences mypy but does nothing for pyright; pyright needs # pyright: ignore[reportGeneralTypeIssues]. A file checked by both requires both comments — and warn_unused_ignores will later flag the mypy one if it becomes redundant.
  • Running mismatched versions in CI: TypeIs results on mypy < 1.10 differ from pyright, and a floating pyright in an editor can differ from the CI pin; pin both exactly, or you will chase phantom narrowing bugs that only reproduce on one machine.
  • Trusting agreement on generic guards: even current versions can differ on nested or constrained generic guards, so never assume parity there — assert the expected type with reveal_type under each tool.
Runtime vs static analysis Neither checker validates that your guard body is *correct* at runtime. `is_str(value)` returning a truthy value for a non-`str` will satisfy both mypy and pyright while crashing in production. The divergences here are purely about static diagnostics; both tools equally trust your implementation.

FAQ

Which checker is “right” when they disagree? Both implement the PEPs; disagreements are usually about inference depth (generic binding, unreachability), not about the core narrowing rule, which is shared. Treat the stricter or more precise result — often pyright’s — as the safer assumption and rewrite your guard so both agree, for example by making a generic binding explicit or by fixing an input type that made an assert impossible. Where soundness is at stake (an unsound TypeIs), both agree it is an error; only the diagnostic differs.

Can I make the negative-branch behaviour identical? Yes — use TypeIs rather than TypeGuard whenever the guarded type is a subtype of the input. Both checkers then narrow the else branch to the complement of the guarded type the same way. If the guarded type is not a subtype (e.g. list[object] to list[str]), you must keep TypeGuard, and then neither checker narrows the else branch — the behaviour is identical, just one-way.

How do I suppress a guard diagnostic for both checkers at once? You cannot with a single comment. mypy reads # type: ignore[narrowed-type-not-subtype] and pyright reads # pyright: ignore[reportGeneralTypeIssues]; put both on the line if both check the file. A cleaner alternative is to fix the guard so no suppression is needed — switch an unsound TypeIs to TypeGuard or correct the guarded type — since a suppression hides a real soundness hole.

Why do results differ between my editor and CI? Almost always a version mismatch: editors bundle their own (often newer, floating) pyright, while CI runs a pinned mypy and pyright. Pin both in your lockfile and, if possible, run a reveal_type smoke test in CI so an inference-changing upgrade is caught deliberately.

Back to TypeGuard & Type Narrowing