“It type-checks in my editor but fails in CI” is a daily tax, and it is entirely avoidable with a five-minute decision. Async code is where the two main checkers diverge most, so it is worth knowing where rather than just that they do.
The setup, and why it happens
Most editors run pyright (it is what Pylance is built on). Most CI pipelines run mypy. They are independent implementations of a spec that is itself still evolving, and they make different choices at the edges — sometimes because one has a bug, more often because the spec genuinely does not say.
Neither is “more correct”. They are differently conservative.
Where async code specifically diverges
PEP 696 defaults on Generator / AsyncGenerator. typeshed gave the
trailing parameters defaults, so Generator[int] means
Generator[int, None, None] and AsyncGenerator[int] means
AsyncGenerator[int, None]. Both checkers support this now; they differed for a
period about whether to apply typeshed’s defaults when the target version is
below 3.13. If you set python_version = "3.12" and write AsyncGenerator[int],
check that both of your checkers agree before relying on it.
PEP 758’s bracketless except*. 3.14 allows except* ValueError, TypeError: without parentheses. Checker support lagged the interpreter, as it
always does for new syntax. If you are on 3.14 and using except* — which
async code does, because that is how you handle TaskGroup failures — write
the parenthesised form until both of your pinned versions parse the new one.
Coroutine versus Awaitable in overload resolution. When an overload set
has both a Coroutine[Any, Any, T] arm and an Awaitable[T] arm, the two
checkers can pick different arms, because a coroutine matches both and the
tie-breaking rules are subtle. This shows up in wrappers around asyncio.run,
gather, and hand-rolled run_sync helpers.
Protocol matching for async methods. Whether async def m(self) -> int
satisfies a Protocol declaring def m(self) -> Awaitable[int] — the subject of
item 9.7 — is agreed on. The edges are less agreed on: generic protocols with
async methods, and Self in async return positions, have historically differed.
Unreachability after await. mypy’s --warn-unreachable and pyright’s
reportUnreachable disagree about code after a call returning NoReturn in an
async context, and about the branch structure of async with bodies.
💡mypy passes, pyright reports an error. How do you decide who is right? click to reveal
Reduce it to the smallest reproducer, then look it up in the typing spec’s
conformance results — github.com/python/typing/tree/main/conformance/results
records, test by test, what each checker does and whether it matches the
specification.
That is the only source that adjudicates rather than asserts. If the spec covers the case, one of them has a bug and you should file it. If the spec does not cover it, neither is wrong and you are in undefined territory — which is itself the answer: rewrite the code so it does not depend on undefined behaviour, because a construct two checkers disagree about is a construct your reader will also disagree about.
Practically, the decision procedure is short: whichever checker gates your CI
is the one that is operative, and the other one’s opinion is advice. Silence
the advice at its source (configure the editor’s checker to match your CI’s
strictness) rather than sprinkling ignores that only one of them will consume —
# type: ignore is mypy’s, # pyright: ignore is pyright’s, and each will
warn about the other’s when --warn-unused-ignores is on.
The decision
Pick one checker as the gate, pin its version, and configure the other to match or disable it.
Pinning is not optional. mypy’s own documentation warns that the --strict
flag list changes between releases — item 0.1 covers the exact thirteen it
enables today — so an unpinned mypy in CI means a new release can fail your
build on code nobody touched. Put mypy==2.3.0 in your lockfile, upgrade it
deliberately, and treat the upgrade as a change.
Then make the editor agree. In VS Code, python.analysis.typeCheckingMode set
to "off" and mypy run through a task is one honest option; matching pyright’s
strictness to your mypy config is the other. What you must not do is leave two
checkers at different strictness levels both surfacing squiggles, because the
team will learn to ignore squiggles.
💡Why not run both in CI and require both to pass? click to reveal
Some teams do, and it is defensible on a library with a wide audience — your users run both, so a construct that trips either is a construct that will generate issues.
The cost is real, though. You now need two suppression comments for every genuine escape hatch, you are blocked on the union of both tools’ bugs, and every dependency upgrade can break the build in two independent ways. On an application, where nobody outside the team type-checks against your code, that cost buys very little.
The middle path most teams land on: mypy gates CI; pyright runs in the editor at a lower strictness so it catches obvious mistakes without producing errors that CI will not; and a library published to PyPI additionally runs pyright in a non-blocking job so you learn about divergences without being blocked by them.