Pyright vs Mypy: Optimizing Type Checking Speed for Large Python Codebases
Pyright runs 3–5× faster than mypy on cold CI runs due to multi-threaded Node.js parsing. For mypy, use the dmypy daemon to eliminate repeated module loading between CI steps. Cache .mypy_cache and ~/.cache/pyright with lockfile-keyed keys, and set NODE_OPTIONS="--max-old-space-size=4096" for pyright on 8 GB runners.
This guide isolates the architectural drivers behind type-checking latency and provides exact configuration steps to minimize overhead in production pipelines. While broader architectural differences are covered in our Pyright vs Mypy Comparison, this page focuses exclusively on throughput optimization: cache management and CI integration patterns for fast feedback loops.
Pyright leverages incremental AST parsing and background indexing, enabling near-instantaneous developer feedback. mypy’s dmypy daemon eliminates repeated module loading overhead but requires explicit cache directory management. CI pipeline timeouts typically stem from unoptimized exclude patterns or missing caches. Proper configuration reduces wall-clock time by 40-70% in most large codebases.
Baseline Benchmarking & Profiling Setup
Establish reproducible performance metrics before applying optimizations — you cannot claim a 40% speedup without a stable baseline to compare against. Measure two distinct scenarios for each tool: a cold run (empty cache, freshly cloned repo, the worst case a CI runner hits) and a warm run (populated cache, the incremental case a developer hits on save). Conflating the two is the single most common benchmarking error; a bare “pyright is faster” claim is meaningless until you state which regime it describes, because the two can differ by an order of magnitude on the same codebase.
Use time for a first-order signal, but prefer hyperfine for anything you intend to act on — it runs multiple trials, discards outliers, and reports a standard deviation, which is what tells you whether a config change actually moved the needle or you just caught a noisy runner.
# Cold vs warm, three trials each, with an explicit cache purge before cold runs
hyperfine --warmup 0 --prepare 'rm -rf .mypy_cache' 'mypy src/' \
--export-markdown mypy_cold.md
hyperfine 'mypy src/' # warm: cache left in place
Both checkers expose internal profiling that attributes wall-clock time to specific phases. mypy’s --verbose prints per-module build steps, and dmypy run -- --perf-stats-file stats.json dumps a machine-readable breakdown of parse, semantic-analysis, and type-checking time. Pyright’s --stats reports the count of analyzed files, the time split between binding and checking, and peak memory:
mypy --verbose src/ 2>&1 | grep -Ei 'build|LOG' # phase-level timing signal
pyright --stats src/ # files, bind/check split, peak RSS
Isolate one-time setup cost from steady-state analysis. On a cold runner, a large fraction of nominal “type-checking time” is actually pip install, stub download (types-* packages), and — for pyright — Node.js interpreter startup, none of which the checker itself controls. Time the install step separately so your tuning targets the analysis phase, not the network. Finally, pin PYTHONPATH and MYPYPATH to fixed, absolute values: mypy keys its cache on the resolved module search path, so a path that shifts between your local shell and the CI job silently invalidates the cache and quietly turns every “warm” run back into a cold one.
# src/sample_module.py — a representative module for the benchmark corpus
from __future__ import annotations
from typing import TypeAlias, Protocol
DataPayload: TypeAlias = dict[str, int | float]
class Validator(Protocol):
def validate(self, payload: DataPayload) -> bool: ...
def process_data(data: DataPayload, validator: Validator) -> None:
if validator.validate(data):
print("Validated successfully.")
Run baseline checks with explicit version constraints so results are reproducible across machines. Pyright >=1.1.330 provides stable incremental parsing; mypy >=1.6.0 provides reliable daemon caching. Record the exact versions alongside your numbers — a pyright minor release can shift timings by 10–20% as inference rules are added or refined, and mypy’s cache format occasionally changes between minor versions, forcing a one-time full rebuild.
# Baseline timing
time pyright src/
time mypy src/
Pyright Incremental Analysis Tuning
The word “incremental” means two different things for pyright, and conflating them wastes tuning effort. In language-server mode (the VS Code extension), pyright keeps the whole program’s binder and type-evaluator state resident and re-analyzes only the dependency cone of a changed file — that is what makes on-save feedback feel instant. In one-shot CLI mode (pyright src/, the CI case), there is no persistent on-disk cache the way mypy has .mypy_cache; each invocation re-parses and re-binds from scratch. Pyright’s cold-run speed therefore comes not from caching but from two things: multi-threaded parsing on the Node.js runtime, and — critically — reading pre-compiled .pyi stubs instead of parsing third-party .py source.
The trade-off is real: with useLibraryCodeForTypes = false, any dependency that ships no stubs collapses to Unknown, so you lose type coverage on it. Set it to false only when your important dependencies are stubbed (either inline py.typed packages or types-* stubs); otherwise the default true buys correctness at the cost of speed. Other high-leverage CLI and config levers:
# pyproject.toml
[tool.pyright]
typeCheckingMode = "strict" # "basic" | "standard" | "strict" — more checks = more work
useLibraryCodeForTypes = false # stubs only; see trade-off above
analyzeUnannotatedFunctions = false # skip inferring bodies of untyped defs
pythonVersion = "3.11" # pin so pyright doesn't probe the environment
pythonPlatform = "Linux" # skip platform-conditional branches for other OSes
exclude = ["**/tests", "**/migrations", "**/node_modules"]
Pin pythonVersion and pythonPlatform explicitly: unset, pyright shells out to locate an interpreter and inspects sys.platform conditionals for every platform, which adds startup cost and can make results non-reproducible across runners. For CI output, use --outputjson (machine-readable diagnostics) and set severity via [tool.pyright] overrides like reportUnusedImport = "warning" so non-critical findings don’t fail the gate. For local development, the VS Code extension handles file watching automatically; in CI, run pyright as a one-shot command — watch mode is never appropriate for a batch job:
NODE_OPTIONS="--max-old-space-size=4096" pyright --outputjson src/
Restricting exclude to source directories is the other big win: every path pyright walks is a path it binds. Excluding tests, generated code, and node_modules shrinks the file set directly, and unlike mypy’s follow_imports, pyright will still resolve imported names from excluded modules — it just won’t report diagnostics on them.
Mypy Daemon (dmypy) & Cache Invalidation Strategies
Standard mypy is stateless: every invocation cold-starts the interpreter, re-imports the world, and rebuilds the type graph from the .mypy_cache on disk. dmypy (the mypy daemon) keeps that fully-analyzed graph resident in a background process and switches mypy into fine-grained incremental mode, where a re-check re-analyzes only the modules whose fingerprints changed plus their reverse dependencies. On a large codebase the second and subsequent dmypy run calls typically return in a fraction of a second, versus tens of seconds for a cold CLI run — the daemon is the single biggest local-feedback lever mypy offers.
Note that dmypy run will auto-start the daemon if none is running, so in a single CI job you can skip the explicit start. The daemon lives only on one machine, though — it does not travel between separate CI jobs or runners, so cross-job speedups come entirely from persisting .mypy_cache via actions/cache, not from the daemon itself. Use --sqlite-cache to store the cache as one SQLite file instead of thousands of tiny JSON files, which restores far faster from a CI cache archive:
# dmypy run auto-starts the daemon; --sqlite-cache packs the cache into one file
dmypy run -- --cache-dir .mypy_cache --sqlite-cache --config-file pyproject.toml src/
# Inspect daemon state and the last check's cost
dmypy status
dmypy run -- --perf-stats-file perf.json src/ # parse/semanal/check breakdown
# Teardown to free memory (the daemon holds the whole type graph in RAM)
dmypy stop
# Prune stale cache metadata (run when dependencies upgrade)
find .mypy_cache -name '*.meta.json' -mtime +7 -delete
PYTHONPATH. Upgrading dependencies changes the underlying type stubs without invalidating the stored hash. Run dmypy stop && rm -rf .mypy_cache after any pip install -U to force a clean rebuild and avoid phantom passes.
Maintaining a persistent process eliminates Python interpreter startup overhead and repeated module import costs. Cache pruning prevents exponential growth in monorepos after repeated dependency upgrades.
CI Pipeline Integration for Fast Feedback
Embed optimized type checking into Static Analysis Tools & CI Integration workflows without blocking PR merges. The winning topology is independent lanes that run concurrently and converge on one required status check: a fast Ruff lane, a mypy lane, and a pyright lane each fan out from checkout and finish in max(lane) rather than their sum. Ruff handles linting and formatting at Rust-native speeds and does no type resolution, so it never contends with the checkers for the semantic work; run it as its own lane purely for early, cheap failure on syntax and import errors.
Set fail-fast: false on any matrix so one Python version’s failure doesn’t cancel the others — you want every version’s diagnostics in a single run, not a race. Cache each checker’s directory with a lockfile-keyed key so a dependency bump correctly invalidates it, and scope the type-check step to your source directory. For pyright, either pip install pyright (which downloads the pinned Node bundle) or use the jakebailey/pyright-action, which surfaces diagnostics as inline PR annotations the way --output-format=github does for Ruff.
# .github/workflows/type-check.yml — independent, cached lanes
name: Type Check
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.11", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache mypy
uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'requirements*.txt') }}
- run: pip install mypy
- run: mypy --python-version ${{ matrix.python-version }} --config-file pyproject.toml src/
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jakebailey/pyright-action@v2
with:
args: --outputjson src/
Running the mypy lane across a version matrix is what catches version-specific type errors — a X | Y union that is valid on 3.10+ but a TypeError on 3.9, for instance. See Matrix-Testing mypy Across Python Versions for the full matrix pattern and caching mypy and pyright in GitHub Actions for the exact cache keys.
Memory Footprint & Resource Optimization
Memory is the resource that actually kills type-check jobs: an OOM manifests as a Killed message and a nonzero exit with no diagnostics, which reads like a crash rather than a resource limit. The two checkers exhaust memory for different reasons. Pyright runs on Node.js, so its ceiling is V8’s old-space heap — roughly 2 GB by default regardless of how much RAM the runner has, which is why large projects need NODE_OPTIONS="--max-old-space-size=4096" to raise it. mypy’s memory instead scales with the size of the type graph it holds, and dmypy keeps that entire graph resident between runs, so a daemon on a big monorepo can sit at several gigabytes indefinitely.
The --max-old-space-size value must stay comfortably below total RAM, not equal it — the OS, the Python venv, and the resident stub packages all need their slice, so 4096 on an 8 GB runner is a safe starting point, not 8192. For mypy, the levers that reduce the graph itself are the effective ones: exclude and follow_imports = skip prune whole subtrees from analysis, and splitting a monorepo into per-package checks (see per-package mypy overrides in a monorepo) keeps any single process’s graph bounded. --jobs N parallelizes mypy but each worker holds its own copy of shared state, so it trades memory for wall-clock — raise it only when you have RAM to spare, not when you are already near the ceiling.
For Docker-based CI runners, install type stubs selectively rather than the full types-* collection — every stub package pyright and mypy load is resident memory:
# Dockerfile snippet for lean CI runners
RUN pip install --no-cache-dir mypy types-requests types-PyYAML pyright
ENV NODE_OPTIONS="--max-old-space-size=4096"
Do not delete .pyi stub files from site-packages to save space — those stubs are exactly what mypy and pyright read for third-party type information. Removing them does not shrink the analysis graph; it forces the checker to fall back to Any/Unknown (or, with useLibraryCodeForTypes = true, to parse the heavier .py source), which can raise memory while destroying type coverage.
Common Mistakes
Every one of these has the same shape: an intuitive-looking configuration that quietly does more work than intended. The fix is always to constrain what the checker loads or how often it cold-starts.
Running standard mypy instead of dmypy in multi-step CI jobs
The standard CLI reloads the entire AST and type environment on every invocation, so a job with three mypy steps pays the cold-build cost three times. Use dmypy run within a single job to persist the graph across steps. Between separate jobs the daemon cannot survive — restore .mypy_cache (ideally --sqlite-cache) via actions/cache so the cold start is at least warm-from-disk.
Enabling useLibraryCodeForTypes = true in pyright for large projects
This forces pyright to parse and infer over all third-party .py source rather than reading pre-compiled .pyi stubs, multiplying both analysis time and peak heap. Leave it false whenever your dependencies are stubbed; only flip it on for a specific un-stubbed dependency you genuinely need typed.
Omitting follow_imports = silent in mypy CI
Left at the default normal, mypy recursively type-checks every imported module including vendored and untyped dependencies, ballooning both runtime and error noise. Set follow_imports = silent in [tool.mypy] to still read third-party stubs for inference while suppressing diagnostics on code you don’t own. Reserve follow_imports = skip for the heaviest offenders where you don’t even need their types.
Letting PYTHONPATH / MYPYPATH shift between environments
mypy keys its cache on the resolved module search path, so a PYTHONPATH that differs between your shell and the CI job silently invalidates the cache and turns every “warm” run back into a full cold rebuild. Pin both to fixed, absolute values in the job environment so the cache actually hits.
FAQ
Why does the mypy cache become stale after dependency upgrades?
mypy caches module hashes based on file content and PYTHONPATH. Upgrading dependencies changes underlying type stubs without invalidating the hash. Run dmypy stop && rm -rf .mypy_cache after pip install -U to force a clean rebuild.
How do I prevent pyright from blocking pre-commit hooks?
Use pass_filenames: false with pyright scoped to your source directory. Pyright in pre-commit runs as a single batch check, not in watch mode.
What is the optimal memory allocation for type checking in CI runners?
Set NODE_OPTIONS="--max-old-space-size=4096" for pyright on standard 8GB runners. For mypy, use --jobs 4 as a starting point and monitor RSS with time -v mypy ... (Linux) to adjust.