mypy vs pyright on TypeGuard and TypeIs
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.
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.
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.
# 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.
# 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:
# 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.
# 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.
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.
- Assuming
TypeGuardnarrows theelsebranch on one checker: Neither does.TypeGuardis one-way in both mypy and pyright. If you need the negative branch, switch toTypeIs(when the guarded type is a subtype of the input), and confirm withreveal_typeon 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 — andwarn_unused_ignoreswill later flag the mypy one if it becomes redundant. - Running mismatched versions in CI:
TypeIsresults 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_typeunder each tool.
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.