Skip to content
← All articles

mypy, pyright, ty — An Honest Comparison

"My code type-checks" is a checker-relative claim. Where mypy and pyright structurally disagree — unannotated bodies, joins vs unions, Unknown vs Any, narrowing, plugins — three divergences verified against mypy 2.3, and why running both is a real strategy.

There is no such thing as “type-checks in Python”. There is only “type-checks under mypy 2.3 with these flags” or “type-checks under pyright 1.1.4xx in strict mode”. The typing spec is real and both tools broadly conform to it, but it leaves a great deal unspecified — inference in particular is almost entirely unconstrained — and the tools fill those gaps differently, on purpose.

This is not an academic distinction. A codebase that is clean under one checker is routinely not clean under the other, and the difference is not noise: it is hundreds of errors, most of them legitimate findings the other tool structurally cannot make.

The five structural differences

1. mypy skips unannotated function bodies. pyright checks everything.

This is the biggest single behavioural gap, and it explains most of the error-count difference on legacy code.

def helper(x):
    return x.no_such_method() + 1

Run mypy with no flags: no output at all. Not “checked loosely” — the body is not analysed. --check-untyped-defs (one of the thirteen in --strict) turns that on. pyright analyses the body unconditionally, infers x as an implicit parameter type, and reports on what it finds.

It follows that a function without a return annotation is Any-returning to mypy and inferred to pyright. So def load(): return {"a": 1} gives mypy a value it will let you do anything with, and gives pyright a dict[str, int] it will hold you to.

2. mypy joins. pyright unions.

When mypy has to combine two types it computes a join — the nearest common supertype. pyright computes a union.

xs = [1, "s"]
reveal_type(xs)   # mypy: list[object]     pyright: list[int | str]

list[object] is a strictly worse answer: you can no longer call .upper() on any element without narrowing, but neither can you catch someone appending a Path. list[int | str] preserves the information. In fairness mypy has narrowed this gap considerably — conditional expressions like 1 if flag else "s" do produce int | str in mypy 2.3 — but the join shows up in list and dict literal inference and in a few other corners, and when it does it silently erases everything you knew.

3. pyright distinguishes Unknown from Any. mypy does not.

Both tools have a type that is compatible with everything. pyright splits it in two: Any is what you wrote, Unknown is what it could not figure out — an untyped import, an unannotated parameter, a partially-typed dependency. They behave identically for checking. They report differently, and reportUnknownParameterType/reportUnknownMemberType let you gate on the second while permitting the first.

That distinction is worth a great deal on a migration, because it separates “we made a considered decision to use Any here” from “we have no idea what this is”. mypy sees one undifferentiated Any and cannot tell you which of your holes were deliberate.

4. pyright narrows in places mypy does not.

Verified against mypy 2.3, both of these leave the type completely unnarrowed:

def handle(cmd: str) -> None:
    if cmd == "start":
        reveal_type(cmd)      # mypy: str
    if cmd in ("start", "stop"):
        reveal_type(cmd)      # mypy: str

pyright narrows both to Literal["start"] and Literal["start", "stop"] respectively. That difference is not cosmetic — it decides whether an exhaustiveness check over a string-literal union works or has to be rewritten around an enum.

5. mypy has plugins. pyright deliberately does not.

Django, SQLAlchemy and several other libraries do enough runtime metaprogramming that no static analysis can follow them without library-specific help. mypy accepts plugins; django-stubs and sqlalchemy‘s mypy integration exist because of it. pyright’s maintainer has consistently declined to add a plugin system, on the grounds that a plugin is arbitrary code that changes what “type-checks” means in ways nobody outside your repo can reproduce.

Both positions are defensible and this one may simply decide the question for you: if you are a Django shop, mypy is not really optional.

💡If pyright checks more, why would anyone still run mypy? click to reveal

Three reasons that survive scrutiny, and one that does not.

Plugins, per above. If your ORM needs one, the decision is made.

Error stability. mypy’s error set changes slowly and its releases are infrequent. pyright ships roughly weekly, and inference improvements — genuinely better analysis — routinely surface new errors in unchanged code. That is a good property in an editor and a hostile one in a CI gate, which is why teams that run both usually gate on mypy and run pyright at the desk. (Both should be pinned regardless.)

The negative-assertion machinery. --warn-unused-ignores plus scoped # type: ignore[code] gives you a way to assert “this line must produce this specific error“, which is how you write a test for a type-level contract. This course depends on it.

The reason that does not survive: “mypy is the reference implementation, so it defines correctness.” It was, and it does not. Both tools are checked against the community conformance suite, and neither passes all of it. Where the spec is explicit, disagreement is a bug in one of them. Where it is silent — which covers most of inference — there is no fact of the matter, and appealing to “the reference implementation” is just picking a side.

Three divergences you will meet in this course

These are not hypothetical. All three were verified against mypy 2.3 and CPython 3.14.

Self-referential PEP 695 bounds. The recursive-bound idiom for a comparable type:

class Comparable[T: Comparable[T]]:
    def less(self, other: T) -> bool: ...

CPython accepts this and evaluates the bound lazily — Comparable.__type_params__[0].__bound__ is Comparable[T], exactly as written. mypy reports Name "T" is not defined [name-defined], on the class definition line, at every strictness level. This is mypy#17347; pyright handles it.

TypeVar(infer_variance=True). CPython has supported this since 3.12 and constructs the TypeVar without complaint. mypy 2.3 reports Unexpected argument to "TypeVar()": "infer_variance" [misc] and then cascades into three more errors as T becomes unusable. The practical consequence: under mypy, PEP 695 syntax is the only route to inferred variance. If you want variance inferred rather than declared, you write class Box[T]: — the old Generic[T] spelling with an infer_variance TypeVar will not check, however correct it is at runtime.

in-narrowing to a Literal. Covered above. Code written against pyright’s narrowing does not port to mypy without an explicit assert or a restructure.

Two more asymmetries worth knowing

Both are cases where the stricter tool is pyright, and both bite in real code.

__all__ contents. mypy’s --no-implicit-reexport (in the thirteen) controls what escapes a module. It does not verify that the names you listed in __all__ actually exist:

__all__ = ["exists", "typo_nmae"]

def exists() -> int:
    return 1

Clean under mypy --strict. pyright reports the typo. Given that __all__ is what from module import * reads and what most documentation tooling reads, a typo there is a silent hole in your public API.

Reading a NotRequired key. A TypedDict key declared NotRequired[str] may be absent at runtime, so reading it directly can raise KeyError:

class User(TypedDict):
    name: str
    nickname: NotRequired[str]

def show(u: User) -> str:
    return u["nickname"]

Clean under mypy --strict. pyright errors at basic, standard and strict. If you use TypedDict for external payloads — which is much of what it is for — this is a KeyError class that one of your two checkers will never mention.

Astral’s ty

ty is Astral’s Rust type checker, from the team behind ruff and uv, and the early performance numbers are extraordinary. It is also, as of mid-2026, still on a 0.0.x version line and explicitly pre-release.

Two honest statements about it. It is worth running today — it is fast enough to sit in your editor alongside whatever you gate on, and it finds things. It is not a safe sole CI gate yet: a checker in 0.0.x has not committed to its diagnostics, its inference, or its configuration surface, and “our build broke because the type checker changed its mind” is a bad Tuesday.

Watch it. Do not bet the pipeline on it yet.

💡A team runs mypy in CI and pyright in the editor. What failure mode does that create, and how would you fix it without doubling the CI time? click to reveal

The failure mode is a class of error that only ever appears at the desk. An engineer writes code that pyright flags, fixes it, and pushes; fine. But the reverse also happens: pyright flags something mypy will never flag, the engineer cannot reproduce the failure in CI, concludes the editor is being fussy, and either suppresses it or — far worse — learns to ignore the squiggles. Within a few months the editor’s diagnostics are decoration.

There is a second-order version that is nastier. Since pyright narrows in and == and mypy does not, code written comfortably against pyright’s narrowing hits mypy in CI and needs an assert inserted to compile. The engineer now believes the CI checker is worse than their editor, which is corrosive to their willingness to take any of it seriously.

The fix is not to run both as blocking gates — that gives you the union of two tools’ opinions and the intersection of their patience. It is to make the disagreement visible and cheap:

Run pyright in CI in non-blocking mode and post its findings as a comment on the pull request, or write them to a dashboard. The engineer sees the same list they saw in their editor, in the shared place, which restores their trust that the squiggles were real. Nothing blocks.

Then use the count as a ratchet: pyright’s error count may go down, never up. That is a one-line CI check against a committed baseline file, it costs nothing, and it converts a second checker from a source of arguments into a source of pressure in one direction.

And configure the editor to match CI on the specific behaviours that differ. Pyright’s strict mode is stricter than mypy’s --strict by design; if your gate is mypy, put pyright on standard at the desk so the routine experience is aligned, and keep strict for the dashboard.

The practical answer

Pick one to gate on and pin its version. If you use Django or SQLAlchemy, that is mypy. If you are greenfield and want the strongest analysis available today, that is pyright.

Then run the other one non-blocking, because the errors it finds are almost entirely errors your gate is structurally incapable of finding — not duplicates, not noise, a different class. Two checkers is not redundancy. It is coverage.