mypy and Pyright: Choosing and Configuring a Python Type Checker

Type hints only pay off when something reads them. Python ignores annotations at runtime, so the work of finding the None that slips into a string operation falls to a separate program, a type checker. Two dominate: mypy, the reference implementation that grew up alongside PEP 484, and Pyright, developed by Microsoft. They read the same annotations and mostly agree on the rules, but their defaults differ enough that the same file can pass one and fail the other.
Same file, two verdicts
Here is a short module with two bugs. One is in an annotated function, the other inside a function that has no annotations at all:
def parse_quantity(text: str) -> int:
return int(text.strip())
def restock(levels):
levels["apples"] = parse_quantity(12)
return levels
def label(name: str | None) -> str:
return name.upper()
mypy 2.4, with its default settings, reported only the second bug:
inventory.py:11: error: Item "None" of "str | None" has no attribute "upper" [union-attr]
Found 1 error in 1 file (checked 1 source file)
Pyright 1.1.414 reported both (output shortened):
inventory.py:6:39 - error: Argument of type "Literal[12]" cannot be assigned to parameter "text" of type "str" in function "parse_quantity" (reportArgumentType)
inventory.py:11:17 - error: "upper" is not a known attribute of "None" (reportOptionalMemberAccess)
2 errors, 0 warnings, 0 informations
The difference is deliberate. mypy treats an unannotated function as dynamically typed and leaves its body alone, so that adding mypy to an old codebase does not produce thousands of errors on day one. Running mypy --check-untyped-defs makes it report the call on line 6 as well, with the error code [arg-type]. Pyright infers what it can everywhere; it does not need annotations on restock to see that 12 is not a string. Neither behaviour is wrong, but you should know which one you are getting before you read a clean report as a clean bill of health.
How the two compare
| mypy | Pyright | |
|---|---|---|
| Written in, installed with | Python, pip install mypy | TypeScript on Node.js, via npm (a community wrapper exists on PyPI) |
| Unannotated function bodies | Skipped unless check_untyped_defs or strict mode | Checked using inferred types |
| Strictness levels | Individual flags, plus --strict | typeCheckingMode: off, basic, standard (the default) or strict |
| Settings file | mypy.ini, .mypy.ini, pyproject.toml or setup.cfg | pyrightconfig.json or pyproject.toml |
| Per-line suppression | # type: ignore[code] | # pyright: ignore[rule], and # type: ignore by default |
| Editor integration | Plugins for most editors, and a daemon (dmypy) for fast re-checks | Built for editors: language server, Pylance in VS Code |
Both handle the modern syntax described in our introduction to type hints. Where they disagree is mostly on inference in edge cases and on which rules are on by default, which is why a project should pick one checker as the reference for CI, even if developers see the other in their editor.
Configuring mypy in pyproject.toml
mypy reads a [tool.mypy] table for global options and any number of [[tool.mypy.overrides]] tables for per-module ones. This set-up checks every function body, enforces annotations on two modules that are already fully typed, and leaves a legacy package unchecked for now:
[tool.mypy]
python_version = "3.12"
files = ["src"]
check_untyped_defs = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = ["shop.billing", "shop.api"]
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = "shop.legacy.*"
ignore_errors = true
With an unannotated total(prices) in shop/billing.py, mypy printed src/shop/billing.py:1: error: Function is missing a type annotation [no-untyped-def], while the deliberate error inside shop/legacy/ was not reported. warn_unused_ignores is worth turning on early: when a fix makes a # type: ignore unnecessary, mypy reports it as [unused-ignore], so suppressions do not pile up. The full --strict flag switches on a long list of options at once; it is easiest to aim for it on new code and reach it on old code one override at a time.
Configuring Pyright in pyproject.toml
Pyright uses a [tool.pyright] table with the same names as its JSON configuration file:
[tool.pyright]
include = ["src"]
exclude = ["src/shop/legacy"]
pythonVersion = "3.12"
typeCheckingMode = "standard"
strict = ["src/shop/billing.py", "src/shop/api.py"]
The strict list does the same job as mypy's overrides: files on it get strict analysis, as if they began with a # pyright: strict comment. Strict mode in Pyright is noticeably demanding. On the same billing.py, it reported not only the missing annotation but also that the parameter's type was unknown and that an unknown value was passed to sum(), and in api.py that the imported function was "partially unknown". Individual rules can also be raised or lowered one by one, for example reportMissingTypeStubs = "warning".
Ignore comments and third-party libraries
When the checker is wrong, or the code is right for reasons the checker cannot see, suppress the one error rather than the whole line:
value = legacy_api.fetch() # type: ignore[no-untyped-call]
A bare # type: ignore hides every present and future error on the line. Pyright honours # type: ignore comments by default and also accepts its own form, # pyright: ignore[reportArgumentType]. A short comment saying why the ignore is needed helps the next reader; our guide to writing useful comments has more on that.
For imported packages, both checkers need type information. PEP 561 defines how a library declares that it ships its own annotations: an empty py.typed file in the package. Libraries without inline types may have stub files (.pyi) maintained in the typeshed project and published on PyPI as packages named types- followed by the library name, such as types-requests. If neither exists, mypy reports a missing import; the narrow fix is an override with ignore_missing_imports = true for that one module, not a global switch.
Running a checker in practice
- Pick a reference checker and pin its version in your development dependencies; new releases add checks and change inference.
- Start permissive: mypy's defaults or Pyright's
standardmode, with legacy packages excluded. - Run it in CI and before each commit; a hook set-up is described in our article on pre-commit hooks. Linting and formatting stay with tools such as ruff and black; a type checker does not replace them.
- Tighten module by module, moving packages into the strict lists as their annotations are completed.
Sources
- mypy documentation, "The mypy configuration file"; also the pages on untyped definitions, error codes and missing imports.
- Pyright documentation, in particular "Configuration" (
typeCheckingMode,strict,enableTypeIgnoreComments). - PEP 561 – Distributing and Packaging Type Information; PEP 484 – Type Hints.
- Outputs above produced on 9 November 2026 with mypy 2.4.1 and Pyright 1.1.414 on Python 3.13.
Docstrings, comments & typesType hints, explained
Annotations do nothing at runtime and a lot for readers and checkers: the syntax from PEP 484 to Python 3.12 generics, formatting rules and gradual adoption.
Linters & formattersRuff: linter and formatter
One tool for linting, import sorting and formatting: how ruff's rule codes work, how to choose them, and what its fixes will and will not change.
Linters & formattersPre-commit hooks
Catch trailing whitespace, unsorted imports and formatting before they reach a commit: git hooks, the pre-commit framework and a configuration to start from.