Pyright in 2026: The Type Checker That Makes mypy Feel Like a Fax Machine

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 Protocol are 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:

  1. Check if types-* stubs exist on PyPI. The typeshed project maintains stubs for popular libraries. pip install types-requests — done.

  2. Check if the library ships inline types. Libraries with a py.typed marker in their package (PEP 561) are fully typed. Pyright picks these up automatically. Most major libraries do this now.

  3. Write local stubs. Create a typestubs/ directory (or whatever you set stubPath to) and drop a minimal .pyi file there. You only need to stub what you actually use, not the entire library.

  4. 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:

  1. Install Pyright, run it in "basic" mode, fix zero-cost errors (undefined names, obvious mismatches).
  2. Silence the rest with # type: ignore comments temporarily (yes, really — you need a green baseline before you can make progress).
  3. Enable "standard" mode. Fix module by module, removing # type: ignore comments as you go.
  4. 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.

👁 Views: 112,863 · Unique visitors: 45,459