Pyright vs Mypy: Architecture, CI Workflows & Strictness Comparison
Choosing between Pyright type checking speed vs mypy requires understanding their underlying execution models, configuration paradigms, and CI/CD integration patterns. This guide provides a technical comparison focused on architectural divergence, strictness alignment, and actionable debugging workflows for production environments.
Teams evaluating Static Analysis Tools & CI Integration should prioritize execution speed, incremental analysis capabilities, and ecosystem compatibility when selecting a primary type checker. Key architectural differences dictate how each tool scales.
mypy relies on Python-native AST traversal, while pyright leverages a TypeScript-based language server. Strictness configuration mapping requires careful translation to avoid behavioral gaps. CI pipeline optimization demands parallel execution, incremental caching, and deterministic failure gating.
Architectural Execution Models & Performance Characteristics
mypy operates as a standalone Python program that parses each source file into an abstract syntax tree, binds names, and then performs semantic (type) analysis. Since mypy 0.780 the PyPI wheels are themselves compiled with mypyc, so the checker you install via pip install mypy runs as native machine code rather than interpreted Python — roughly a 4× speedup over a source checkout. Analysis is single-process by default; --jobs N (alias -j) forks worker processes on POSIX systems to parallelize per-module checking, but the fan-in during cross-module inference limits how far that scales on tightly coupled codebases.
Two incremental modes cut repeat cost. The on-disk cache under .mypy_cache/ stores a .data.json and .meta.json per module; a second invocation re-reads those and only re-checks modules whose hashes changed. The dmypy daemon (dmypy start, then dmypy run -- src/) keeps the entire type graph resident and uses fine-grained incremental analysis, re-checking only the strongly-connected components an edit touches — this is what brings warm runs to sub-second on large trees. Because memory scales with that graph, CI jobs on 500k-line monorepos often bound the blast radius with --follow-imports=skip or by pointing mypy at specific packages rather than the repo root.
Pyright ships as a Node/TypeScript program. pip install pyright installs a thin Python launcher that downloads a pinned Node runtime on first use; teams wanting a pure-Python, pip-native distribution increasingly use the basedpyright fork. The command line is a batch driver over the same engine that powers the language server, so pyright --watch gives continuous re-analysis, --outputjson emits machine-readable diagnostics, and --stats prints binding and checking timings. Pyright’s decisive speed edge comes less from threading than from lazy, demand-driven evaluation: it infers a symbol’s type only when something depends on it and memoizes the result, whereas mypy eagerly checks every definition. On cold runs pyright is frequently 3–5× faster; on very large repositories, raise the V8 heap with NODE_OPTIONS=--max-old-space-size=8192 to avoid JavaScript heap out of memory.
When optimizing feedback loops, consider pairing either checker with Ruff Linter Integration to offload syntax validation and import sorting so type inference is the only work on the hot path. For head-to-head timing methodology, see Pyright type checking speed vs mypy.
Strictness Configuration & Type Inference Parity
Achieving equivalent strictness across both tools requires explicit, per-flag mapping rather than a single switch. mypy’s strict = true is shorthand that turns on more than a dozen independent options — among them disallow_untyped_defs, disallow_incomplete_defs, disallow_untyped_calls, disallow_any_generics, check_untyped_defs, warn_return_any, warn_redundant_casts, warn_unused_ignores, no_implicit_reexport, and strict_equality. Run mypy --help to see the exact set for your version, because new checks are periodically folded into strict. Pyright instead exposes four umbrella levels through typeCheckingMode — off, basic, standard (the CLI default), and strict — each of which sets dozens of individual report* rules to "none", "warning", or "error".
Because the two engines use different narrowing algorithms and stub-resolution orders, copying strict settings verbatim surfaces different diagnostics on the same code. The most common divergence is pyright’s Unknown type: in strict mode pyright flags values whose type it cannot fully infer (via reportUnknownVariableType, reportUnknownMemberType, reportUnknownParameterType), whereas mypy silently treats the same values as Any. Migrating a “clean” mypy codebase to pyright strict therefore usually produces a wave of new report* errors in previously untyped corners.
# pyproject.toml — mypy: strict everywhere, relaxed for a legacy package
[tool.mypy]
strict = true
warn_return_any = true
warn_unused_configs = true
[[tool.mypy.overrides]]
module = "legacy.*"
disallow_untyped_defs = false
warn_return_any = false
// pyrightconfig.json — pyright: standard baseline, strict for core paths
{
"typeCheckingMode": "standard",
"strict": ["src/core", "src/api"],
"reportUnnecessaryTypeIgnoreComment": "warning",
"reportMissingTypeStubs": true
}
The two snippets show the tools’ opposite ergonomics for scoped strictness. mypy layers per-module [[tool.mypy.overrides]] sections on top of a global baseline; pyright lists directories in its strict array so a subset of the tree is checked at the highest level while the rest stays at standard. Stub resolution also differs: mypy searches bundled typeshed plus anything on MYPYPATH and honors --python-executable/python_executable to locate installed third-party stubs, while pyright discovers the environment through venvPath+venv (or --pythonpath) and reads local overrides from a typings/ directory. A mismatched interpreter is the usual cause of “works locally, fails in CI” stub errors — mypy emits error: Cannot find implementation or library stub for module named "x" [import-untyped]; pyright emits reportMissingImports.
Unknown (a stricter concept than Any) by default in strict mode, catching more issues in untyped code than mypy does with its default Any inference. If you switch from mypy to pyright, expect a wave of new errors in code that was previously "clean" under mypy.
For baseline strictness tuning before cross-tool migration, reference Mypy Configuration & Strictness to establish a controlled rollout strategy.
CI/CD Integration Patterns & Workflow Automation
Embedding type checkers into modern CI pipelines requires deterministic gating and cache-aware execution. Running both tools in a matrix strategy prevents sequential bottlenecks: rather than checking mypy then pyright in one job, a two-axis matrix (checker × Python version) fans out into independent runners that complete in parallel, so total wall-clock time is bounded by the slowest single cell rather than the sum. Each cell must pin its own cache keyed on the Python version, the dependency lockfile hash, and the checker version, because a cache written by a different combination is silently discarded.
name: Type Check Matrix
on: [push, pull_request]
jobs:
type-check:
runs-on: ubuntu-latest
strategy:
matrix:
checker: [mypy, pyright]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
- run: pip install ${{ matrix.checker }}
- run: ${{ matrix.checker }} src/
This shows a scalable CI pattern that runs both checkers in parallel. For mypy, append --cache-dir .mypy_cache and cache that directory with actions/cache keyed on ${{ runner.os }}-mypy-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt') }} to preserve incremental state; the dmypy daemon offers no benefit on a fresh runner, so use plain mypy in CI. For pyright, --stats outputs binding and checking timings, and --outputjson emits diagnostics you can feed to a problem matcher so failures surface as inline PR annotations; mypy’s equivalent is --junit-xml report.xml. Both tools signal failure through their exit code — non-zero on any error — which is what actually gates the merge, so never wrap the command in || true and keep continue-on-error unset.
Deterministic gating also means being explicit about severity. Pyright treats only "error"-level rules as build-failing; a rule dialled down to "warning" prints but returns exit code 0, so a project that expects pyright to block on reportMissingTypeStubs must set it to "error" in pyrightconfig.json. mypy has no severity ladder — any reported error fails the run — so the parity work is ensuring the pyright rule set that gates matches the mypy checks that gate. Handle flaky type errors by excluding dynamically generated modules via exclude directives (a regex in mypy, a glob array in pyright), and establish a baseline of existing errors with mypy --strict . > baseline.txt or pyright’s --baseline workflow before enabling strict enforcement, so the gate blocks only new violations while the backlog is burned down. See GitHub Actions type checking for a complete caching workflow.
Debugging Complex Inference Failures & Modern Syntax
Advanced Python features expose engine-specific inference gaps. Systematic debugging requires isolating the failing expression and inspecting the inferred type hierarchy, then routing the discrepancy by its cause: a stub-resolution difference, a narrowing-algorithm difference, or a genuine bug in your annotations.
Start by inserting reveal_type(variable) at the failure point. Both checkers will emit the resolved type to stderr.
from typing import TypeGuard
def is_valid_config(data: dict[str, object]) -> TypeGuard[dict[str, str]]:
return all(isinstance(v, str) for v in data.values())
def process(data: dict[str, object]) -> None:
if is_valid_config(data):
reveal_type(data) # Both mypy and pyright: dict[str, str]
Note that reveal_type is a special form both checkers recognise without an import (it is injected into every scope during checking); calling it at runtime raises NameError unless you from typing import reveal_type, standardized in Python 3.11. A companion reveal_locals() dumps every binding in scope, which is faster than annotating suspects one at a time.
The match statement is a frequent divergence point. Exhaustiveness is not inferred identically: pyright is more willing to treat a closed set of Literal or enum cases as exhaustive and narrow the fall-through to Never, whereas mypy often needs an explicit case _: arm. Make both agree by adding assert_never() in the default arm — both checkers then error with Argument 1 to "assert_never" has incompatible type the moment a new union member is added but not handled:
from typing import assert_never, Literal
def handle(cmd: Literal["start", "stop"]) -> str:
match cmd:
case "start": return "go"
case "stop": return "halt"
case _: assert_never(cmd) # both error if a case is missing
For user-defined narrowing, prefer TypeGuard (PEP 647, 3.10+) when the guarded type is unrelated to the input, and TypeIs (PEP 742, 3.13 or typing_extensions) when it is a subtype, because TypeIs also narrows the negative branch — mypy and pyright both implement the newer TypeIs semantics, so it removes a class of disagreements where TypeGuard left the else branch un-narrowed. Protocol and TypedDict discrepancies often stem from total=False: a key declared with total=False (or wrapped in NotRequired, PEP 655) may be absent, and pyright and mypy can differ on whether accessing it without a presence check is an error — gate every optional key behind if "k" in d: or .get() so both agree. Finally, validate PEP 695 (type aliases and class C[T] syntax) by pinning versions: it needs mypy >=1.11 with --python-version 3.12 (enabled by default from 1.12) and a recent pyright; on older releases the type X = ... statement raises [valid-type] in mypy or is silently ignored, which reads as an inexplicable inference gap until you check the version.
Common Implementation Pitfalls
- Assuming strict mode parity guarantees identical error reporting: mypy and pyright implement different type narrowing algorithms and stub resolution orders. Directly copying strict flags without validating against your codebase will produce divergent false positives and missed errors.
- Ignoring incremental cache invalidation in CI pipelines: Both tools cache analysis results, but cache keys must include Python version, dependency hashes, and checker version. Stale caches cause phantom passes or unexplained failures in PR checks.
- Overusing
# type: ignoreinstead of fixing underlying inference gaps: Suppressing errors masks architectural type leaks. Usereveal_type()to inspect inferred types and refactor function signatures or add explicitTypeAliasdeclarations before applying suppression.
Frequently Asked Questions
Can I run pyright and mypy simultaneously in the same CI pipeline? Yes, but run them in parallel matrix jobs to avoid compounding execution time. Use separate cache directories and ensure both check against the same dependency lockfile to maintain consistency.
Which tool handles PEP 695 type parameter syntax better?
Both support PEP 695, but pyright’s implementation aligns more closely with its TypeScript-based parser. mypy requires >=1.12 and an explicit --python-version 3.12 flag. Verify your Python version compatibility before enabling.
How do I migrate from mypy to pyright without breaking CI?
Start by running pyright in off or basic mode alongside mypy. Gradually enable standard and strict reporting categories, fixing divergences incrementally before switching the CI gate.
Does pyright replace the need for a separate linter? No. Pyright focuses on type inference and static analysis. Pair it with a dedicated linter like Ruff for style enforcement, import sorting, and fast syntax validation to maintain comprehensive code quality.