If your Python project still runs mypy in CI and you’ve never questioned why, this article is for you.
mypy has been the default answer to "how do I type-check Python" for years. It pioneered the space, it’s mature, and half the tutorials on the internet point to it. But maturity in software sometimes means "accumulated technical debt wrapped in community inertia." mypy is slow, its daemon is finicky, and its incremental mode has a well-earned reputation for producing stale caches that lie to you.
Pyright — Microsoft’s type checker written in TypeScript and powering Pylance in VS Code — is a fundamentally different beast. On large codebases it runs 5–10x faster than mypy cold, and the gap widens with the daemon. It catches more bugs in practice. Its error messages are, frankly, better.
This guide walks through setting up Pyright properly in 2026: local dev, CI, pre-commit, and the configuration knobs that matter. We’ll also cover the real gotchas, because the internet is full of "just pip install pyright" posts that leave you debugging false positives for a week.
Official repo: https://github.com/microsoft/pyright
Why Pyright Won
The architectural reason Pyright is faster is straightforward: it was written in TypeScript and runs on V8, which has a world-class JIT. mypy is pure Python — great for hackability, not great for throughput. Microsoft built Pyright to power Pylance in VS Code, which means it needs to respond to keystrokes in real time. That design constraint produced a type checker that’s genuinely fast.
Beyond speed, Pyright has stricter and more correct inference in several areas:
- TypeGuard and type narrowing — Pyright’s narrowing is more aggressive and accurate. It tracks assignments through branches correctly in cases where mypy gives up and widens back to the declared type.
- Overload resolution — especially with generics, Pyright resolves overloads closer to what the runtime actually does.
- Protocol structural matching — Pyright’s structural subtyping checks for
Protocolare more complete. - PEP compliance speed — new PEPs land in Pyright faster. By the time mypy fully supports something from Python 3.12 or 3.13, Pyright has usually had it for six months.
This doesn’t mean mypy is wrong and Pyright is right on every disagreement. Both have edge cases where their inference model diverges from the other. But for day-to-day work, Pyright catches more real bugs with fewer false positives on a modern Python codebase.
Installation
There are two distributions: the pyright npm package (the canonical one) and the pyright PyPI package (a thin wrapper that bundles Node). For most Python projects, use the PyPI package — it means one fewer tool in your environment.
pip install pyright
Or if you’re using uv (which you should be in 2026):
uv add --dev pyright
Confirm it works:
pyright --version
You should see something like pyright 1.1.39x. The version number has been in the 1.1.3xx range for a while — Pyright follows a rolling release model, not semver. Patch-level releases ship frequently and rarely break things.
Configuration
Pyright reads config from two places: pyrightconfig.json in the project root, or a [tool.pyright] table in pyproject.toml. The pyproject.toml approach is cleaner for projects that already have one.
Here’s a production-ready pyproject.toml configuration:
[tool.pyright]
# Tell Pyright which Python version and environment to use
pythonVersion = "3.12"
pythonPlatform = "Linux"
# Point to your virtual environment so Pyright resolves third-party types
venvPath = "."
venv = ".venv"
# The typeCheckingMode is the single most important knob.
# "basic" catches obvious errors, "standard" is the sweet spot,
# "strict" turns on everything including inferred return types.
typeCheckingMode = "standard"
# Paths Pyright should analyze. Default is the root; narrow this
# if you have generated code or vendored libraries you don't own.
include = ["src", "tests"]
exclude = ["**/node_modules", "**/__pycache__", "src/generated"]
# Extra paths for stub resolution (rarely needed with a proper venv)
# stubPath = "typestubs"
# Suppress specific error codes project-wide only as a last resort.
# Prefer inline `# type: ignore[error-code]` for surgical suppression.
reportMissingImports = "error"
reportMissingTypeStubs = "warning"
If you prefer a standalone pyrightconfig.json:
{
"pythonVersion": "3.12",
"pythonPlatform": "Linux",
"venvPath": ".",
"venv": ".venv",
"typeCheckingMode": "standard",
"include": ["src", "tests"],
"exclude": ["**/node_modules", "**/__pycache__", "src/generated"],
"reportMissingImports": "error",
"reportMissingTypeStubs": "warning"
}
The typeCheckingMode Ladder
"basic" catches undefined names and obvious type mismatches. Good for adding Pyright to a legacy codebase without drowning in errors day one.
"standard" (recommended) — enables most checks. Inferred return types aren’t required, but explicit annotations are checked strictly. This is where you want to be.
"strict" — every function must have explicit return types and parameter annotations. It will light up an untyped legacy codebase like a Christmas tree. Don’t enable strict on a codebase that isn’t already well-annotated unless you have a week to burn fixing errors.
The migration path is: get to zero errors on "standard", then gradually move to "strict" module by module using per-file overrides.
Running It
Basic run against the whole project:
pyright
Against a specific file or directory:
pyright src/mymodule/core.py
pyright src/mymodule/
JSON output for tooling integration:
pyright --outputjson | jq '.generalDiagnostics[] | select(.severity == "error")'
The --outputjson flag is underused. It produces structured output you can pipe into scripts, post-process, or feed to a custom reporter.
CI Integration
This is where the speed difference becomes visceral. Here’s a GitHub Actions workflow:
# .github/workflows/typecheck.yml
name: Type Check
on:
push:
branches: [main, develop]
pull_request:
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: pip install uv
# Cache the venv by hashing lockfile — Pyright needs the venv
# to resolve third-party package types.
- name: Cache virtualenv
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Run Pyright
run: uv run pyright --outputjson | tee pyright-results.json
# Non-zero exit on any error (default behavior, just making it explicit)
- name: Upload Pyright results
if: always()
uses: actions/upload-artifact@v4
with:
name: pyright-results
path: pyright-results.json
On a 60k-line codebase this typically runs in 8–12 seconds. The equivalent mypy run with caching primed takes 25–40 seconds cold, and the cache is environment-dependent enough that CI often can’t reuse it effectively.
pre-commit Hook
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pyright
name: pyright
entry: pyright
language: system
types: [python]
# Run once across all staged files rather than per-file —
# Pyright does its own multi-file analysis pass.
pass_filenames: false
The pass_filenames: false is important. Pyright analyzes the whole project graph, not just changed files, because a change in one file can break inference in another. Passing individual filenames tells it to only check those files, which means you can push a breaking change to an un-staged file and the hook won’t catch it.
Handling Third-Party Libraries Without Stubs
This is where most people hit their first real wall with Pyright. You install requests, run Pyright, and get:
Import "requests" could not be resolved from source (reportMissingModuleSource)
Or worse, everything resolves but you’re getting Unknown types throughout because the library ships no py.typed marker and no bundled stubs.
The right fix, in order of preference:
-
Check if
types-*stubs exist on PyPI. The typeshed project maintains stubs for popular libraries.pip install types-requests— done. -
Check if the library ships inline types. Libraries with a
py.typedmarker in their package (PEP 561) are fully typed. Pyright picks these up automatically. Most major libraries do this now. -
Write local stubs. Create a
typestubs/directory (or whatever you setstubPathto) and drop a minimal.pyifile there. You only need to stub what you actually use, not the entire library. -
Suppress per-module. In
pyproject.toml:
[tool.pyright]
reportMissingTypeStubs = "none" # nuclear option, kills the warning globally
Or surgical suppression in code:
import some_untyped_lib # type: ignore[import-untyped]
Gotchas
py.typed and editable installs. If your own package is installed in editable mode (pip install -e .) and doesn’t have a py.typed marker, Pyright may not pick up your source types correctly. Add an empty py.typed file to your package root and include it in package_data.
Pyright and mypy disagree — who’s right? Neither. They implement the same spec (PEP 484 and friends) but with different interpreters. When they disagree on a real-world type, check the PEP. In my experience Pyright is more often correct on narrowing and generics, mypy is sometimes more lenient in ways that catch real bugs less. When in doubt, write a minimal reproducer and check the respective issue trackers.
TYPE_CHECKING guards. Code under if TYPE_CHECKING: is analyzed by Pyright but not executed at runtime. Pyright is generally smarter about this than mypy, but you can still trip over circular imports that only exist under TYPE_CHECKING if you import a type that itself has a side-effectful import chain. Keep TYPE_CHECKING imports to forward references and annotation-only types.
The --pythonpath vs venv problem. If you run pyright without a configured venv and your virtual environment is activated in the shell, Pyright may or may not pick up the right environment depending on how you installed it. Always configure venvPath + venv explicitly. Don’t rely on shell activation state in CI.
Strict mode on *args and **kwargs. In strict mode, def foo(*args, **kwargs) is an error — you must annotate. This is the single most common complaint from teams migrating to strict. The fix is *args: Any, **kwargs: Any for cases you genuinely can’t type, or proper TypeVar/overload signatures where you can.
Pyright doesn’t run your decorators. If you have a decorator that mutates the return type of a function at runtime (common in ORMs, FastAPI, etc.), Pyright has to infer the resulting type statically. For FastAPI this works via its own stubs. For custom decorators, you need to annotate them properly with ParamSpec and generics, or the return type will be None or Unknown. This isn’t a Pyright bug — it’s a fundamental constraint of static analysis.
Moving from mypy to Pyright
If you have a working mypy setup and want to migrate, the path is:
- Install Pyright, run it in
"basic"mode, fix zero-cost errors (undefined names, obvious mismatches). - Silence the rest with
# type: ignorecomments temporarily (yes, really — you need a green baseline before you can make progress). - Enable
"standard"mode. Fix module by module, removing# type: ignorecomments as you go. - Delete mypy from your project.
The last point is contentious — some teams run both for a transition period. I don’t recommend it. Two type checkers produce conflicting errors and competing silencing comments, and you end up maintaining two configurations indefinitely. Pick one. Pyright is the right pick in 2026.
If you have a large mypy.ini with per-module overrides for third-party libraries, translate those to Pyright’s reportMissingTypeStubs and reportUnknownMemberType settings. Most mypy ignore_missing_imports entries map directly to Pyright’s reportMissingImports = "none" at the module level via:
{
"executionEnvironments": [
{
"root": "src/legacy_module",
"reportMissingImports": "none"
}
]
}
Per-file Strictness Overrides
You don’t have to enable strict mode project-wide. Pyright supports per-file overrides via inline comments:
# pyright: strict
# (put at the top of the file)
Or to relax a specific check in a file:
# pyright: reportUnknownVariableType=none
This lets you adopt strict mode incrementally — annotate a module completely, add the comment, and the CI enforces that no one regresses it. Over six months, you migrate the whole codebase this way without a big-bang rewrite.
Editor Integration
If you’re using VS Code, Pylance already uses Pyright under the hood. The pyrightconfig.json or [tool.pyright] in your project is picked up automatically. You don’t need to install Pyright separately for the editor.
For Neovim with nvim-lspconfig, use pyright as the LSP server:
require('lspconfig').pyright.setup({
settings = {
python = {
analysis = {
typeCheckingMode = "standard",
-- Use the project's venv automatically
autoSearchPaths = true,
useLibraryCodeForTypes = true
}
}
}
})
The LSP server is the same binary as the CLI checker. There’s no config duplication — the server reads your pyrightconfig.json or pyproject.toml just like the command line does.
When to Stay on mypy
There are legitimate reasons to keep mypy:
- django-stubs and other mypy plugins. The mypy plugin ecosystem is mature. django-stubs, sqlalchemy[mypy], and similar libraries ship mypy plugins that generate precise types from runtime-inspected code. Pyright handles some of these through alternative stub packages, but the coverage isn’t equivalent. If you’re heavily invested in Django ORM type safety via django-stubs, evaluate carefully before switching.
- Team familiarity. If your team has five years of mypy muscle memory and your codebase is already green on mypy strict, the migration cost may not pay off. Speed matters most when you’re running type checks hundreds of times a day.
- Existing tooling integrations. Some internal tools or pipelines are built around mypy’s JSON output schema. Pyright’s output format is different.
These are real tradeoffs, not excuses. But for a new project in 2026, or a project where CI type checking is a meaningful bottleneck, Pyright is the default choice.
The Bottom Line
Pyright is faster, catches more bugs in the cases that matter most (narrowing, generics, protocols), and has better editor integration out of the box because it is the editor integration. The configuration is straightforward, the migration from mypy is mechanical, and the CI story is clean.
The only reason to hesitate is if you’re locked into a mypy plugin that has no Pyright equivalent. For everything else, the friction of migrating is lower than the ongoing cost of slower type checking and missed errors.
Get the venv configured correctly, set typeCheckingMode = "standard", wire it into CI, and then spend the next three months ratcheting up to strict one module at a time. That’s the whole plan.