Enabling Pyright Strict Mode Incrementally
Don’t flip pyright to strict repo-wide. Use pyrightconfig.json with a strict: ["packages/core"] include list so only ready packages are strict, mark individual files with a # pyright: strict pragma, and scope per-directory rules with executionEnvironments. Ratchet packages from basic to strict one entry at a time, turning reportMissingTypeStubs from a warning into an error as stubs land.
Pyright has three type-checking modes — off, basic, and strict — and a global typeCheckingMode: "strict" will light up hundreds of reportUnknownMemberType and reportMissingTypeStubs diagnostics across an unprepared monorepo. Pyright (since the config gained per-path strictness) lets you apply strict to a curated set of paths while the rest stay at basic. This page walks the gradual rollout: include lists, per-file pragmas, and executionEnvironments, ending at a clean per-package ratchet.
Step 1: a basic-everywhere baseline
Pyright’s three modes are presets over the same set of roughly ninety report* diagnostic rules, not distinct engines. off sets almost every rule to "none": pyright still parses the file, flags syntax errors, and resolves imports, but the entire reportUnknown* family and most correctness checks are silenced — useful for a directory you have no intention of typing yet. basic turns on the rules that catch genuine bugs while tolerating gaps: reportMissingImports, reportMissingModuleSource, reportOptionalMemberAccess, reportOptionalSubscript, reportArgumentType, reportReturnType, and reportAssignmentType are all active, but the “is this type fully known?” rules stay off, so a partially-typed dependency doesn’t bury you in noise. strict promotes that second tier to errors: the whole reportUnknown* family (reportUnknownMemberType, reportUnknownArgumentType, reportUnknownVariableType, reportUnknownParameterType, reportUnknownLambdaType) plus reportMissingTypeStubs, reportPrivateUsage, reportUntypedFunctionDecorator, reportUntypedBaseClass, and the reportUnnecessary* cleanups. Recent pyright (roughly 1.1.3xx onward) added a fourth preset, standard, now the default, sitting between basic and strict; the mechanics below work identically whichever floor you pick.
Start with basic as the floor so the whole repo is at least loosely checked, then declare which paths are strict. The strict array takes paths (not modules), evaluated relative to the config file.
// pyrightconfig.json — pyright 1.1.x
{
"typeCheckingMode": "basic",
"include": ["packages"],
"exclude": ["**/node_modules", "**/.venv", "**/build"],
"strict": ["packages/core", "packages/sdk"],
"reportMissingImports": "error"
}
What the analyzer sees: files under packages/core and packages/sdk are checked at full strict (every reportUnknown* becomes an error); everything else under packages stays at basic, where unknown types are tolerated. New packages default to basic until you add them to strict. The strict entries are file-or-directory paths resolved relative to the config file, not dotted module names — the distinction trips up anyone coming from mypy’s [[tool.mypy.overrides]] module = "..." globs. Matching is prefix-based, so packages/core also covers packages/core/sub/deep.py. Pinning reportMissingImports to "error" is belt-and-braces: it is already an error under basic, but naming it documents intent and survives a future change to pyright’s defaults. Prefer keeping the whole file layout in one place; scattering strict paths across several configs makes the ratchet’s current state hard to read at a glance. Note that a top-level typeCheckingMode and a strict list are complementary, not alternatives: the mode sets the floor for every included file, and strict raises named paths above it. Omitting typeCheckingMode entirely leaves the floor at pyright’s own default, which is standard in current releases and was basic before the standard preset existed — worth setting explicitly so a pyright upgrade doesn’t silently change what your untyped packages report.
Step 2: promote individual files with a pragma
Before a whole package is ready, you can make one file strict with an inline pragma. This is the finest-grained ratchet step. The # pyright: strict comment is a file-scoped directive: pyright scans the file’s comments for # pyright: lines and applies the mode to the entire file regardless of where the comment sits (top of file is the convention). Because a pragma sits at the very top of the resolution order, it beats the directory’s executionEnvironments entry and the strict/include path settings alike — so a single file can be strict inside an otherwise-basic package, and, symmetrically, # pyright: basic can carve one stubborn module out of a strict package while you deal with it.
# packages/api/parser.py — pyright 1.1.x
# pyright: strict
def parse_response(payload: dict[str, int]) -> int:
return sum(payload.values())
The # pyright: strict comment overrides the file’s mode regardless of the directory’s setting. You can also flip individual rules per file — # pyright: reportUnknownMemberType=false — to land a strict file that still has one tolerated gap, then remove the suppression later. Per-rule pragmas take a comma-separated list and accept either a severity or a boolean, so # pyright: reportUnknownMemberType=false, reportPrivateUsage=warning is valid, with false/true acting as shorthand for none/error. This is how you land a file at strict with one deliberately tolerated gap — typically a reportUnknownMemberType leaking out of an untyped client library — and then delete the suppression the moment a stub arrives. Mypy has no file-level “make this strict” switch to compare against; its nearest analogues are a per-module block in per-package mypy overrides or a top-of-file # mypy: disallow-untyped-defs comment.
Step 3: scope rules with executionEnvironments
executionEnvironments apply settings to a subtree — different pythonVersion, extra import roots, or relaxed rules for a legacy directory while the repo default stays strict. It is an ordered array; each entry needs a root directory and may override pythonVersion, pythonPlatform, extraPaths, and any report* rule. When pyright analyzes a file it selects the entry whose root is the longest matching prefix of the file’s path, so a deeply nested subtree wins over a broader one and a file matching no entry falls back to the top-level settings. Crucially, an entry is an override layer, not a full reset: rules it does not name are inherited from the top level, so silencing one diagnostic for legacy_etl leaves the rest of strict intact there.
// pyrightconfig.json — per-directory rule scoping, pyright 1.1.x
{
"typeCheckingMode": "strict",
"include": ["packages"],
"executionEnvironments": [
{
"root": "packages/legacy_etl",
"reportUnknownMemberType": "none",
"reportMissingTypeStubs": "none",
"extraPaths": ["packages/legacy_etl/vendor"]
},
{
"root": "packages/core"
}
]
}
Here the repo is globally strict, but packages/legacy_etl silences the two noisiest unknown-type diagnostics and adds a vendored import path. packages/core inherits the strict default with no relaxations. extraPaths is how you teach pyright about import roots that are not installed into the interpreter’s environment — vendored third-party code, generated protobuf modules, or a src/-layout package — making it the static-analysis counterpart of a runtime PYTHONPATH or sys.path entry. Set pythonVersion per environment when one subtree targets an older interpreter, since it governs which syntax and standard-library symbols pyright treats as available; pythonPlatform (Linux, Darwin, Windows, or All) similarly controls which platform-guarded branches are analyzed. A subtree can also point venvPath/venv at a different virtual environment when packages install their own isolated dependencies, so pyright resolves each subtree’s imports against the interpreter that actually runs it.
# pyright: pragma overrides the matching executionEnvironments entry, which overrides the strict/include path settings, which override the top-level typeCheckingMode. So a single file can opt into strict ahead of its package, and a directory can opt out without touching siblings.
Step 4: ratchet basic → strict, then handle stubs
The migration loop per package: add it to strict, run pyright, fix or pragma-suppress the diagnostics, then remove suppressions over time. The most common blocker is third-party libraries without stubs, which strict mode reports as reportMissingTypeStubs. Whether a dependency triggers it comes down to PEP 561: a package that ships a py.typed marker file advertises inline annotations, and pyright reads them directly, so the rule never fires. Libraries that predate PEP 561 have no marker, and strict mode flags every import of them.
// pyrightconfig.json — tighten stub policy as the package matures, pyright 1.1.x
{
"strict": ["packages/core"],
"reportMissingTypeStubs": "warning", // start as a warning…
// …then promote to "error" once stubs are installed or bundled
"stubPath": "typings" // local stub overrides for stubless deps
}
Drop a .pyi into typings/<package>/ for any dependency that ships none, then flip reportMissingTypeStubs to "error" so regressions can’t reintroduce an untyped dependency. There are three fixes for a stubless library, in order of preference: install a community stub-only distribution (the types-* packages on PyPI — types-requests, types-PyYAML, and hundreds more from the typeshed third-party set), which pyright discovers automatically; vendor a partial stub into stubPath (default typings), where a single <package>/__init__.pyi covering only the symbols you actually call is enough to clear the diagnostic; or, as a last resort, silence reportMissingTypeStubs for that subtree via executionEnvironments. Keep the rule at "warning" while stubs are landing so progress stays visible, then promote it to "error". If you want a stronger ratchet than stock pyright offers — a baseline file that freezes today’s error count so only new violations fail CI — basedpyright, a community fork tracking upstream pyright, adds exactly that plus a few extra diagnostics; it is worth a look for large migrations.
# pyright: strict pragma and the strict include list change only what pyright reports — they alter no runtime behaviour. A file that passes strict and one that's off execute identically; strict simply refuses to let unknown types through at analysis time.
Edge cases
- Pragma vs config conflict. A
# pyright: basicpragma wins even if the file’s directory is in thestrictlist. Because the pragma sits at the top of the resolution order, no config change can override it — audit for stray pragmas (grep -rn "# pyright: basic") before assuming a package is fully strict, or a “green” package may be quietly demoted file by file. reportMissingModuleSource. Installing a stub-only package (atypes-*distribution) with no runtime package present triggers this; it’s informational, distinct fromreportMissingImports, and usually safe to leave atwarning. It means “I found type information but no source to run” — common in a lint-only CI job that installstypes-*packages but not their runtime counterparts.
extraPathsand namespace packages. Strict mode surfaces import resolution gaps thatbasictolerated. If a namespace package resolves at runtime but pyright reportsreportMissingImports, add its root toextraPathsin the relevantexecutionEnvironmentsentry.- Stale suppression comments.
reportUnnecessaryTypeIgnoreCommentflags# type: ignoreand# pyright: ignorecomments that no longer suppress anything — a useful ratchet companion once a package is clean. Note it defaults to"none"even understrict, so you must enable it explicitly, and it can clash with a shared codebase where mypy still needs the# type: ignorethat pyright now considers redundant. - Pyright and mypy disagree. The two checkers infer independently: pyright deduces variable and return types far more aggressively and its
reportUnknown*family has no single mypy flag equivalent (the nearest are--disallow-any-exprand--disallow-untyped-defs). A file that passesmypy --strictcan still emit dozens of pyright-strictreportUnknownMemberTypeerrors, and the reverse happens too — see the pyright vs mypy comparison for where they diverge.
Common mistakes
- Global
typeCheckingMode: "strict"on day one. Floods CI withreportUnknownMemberType,reportUnknownArgumentType, andreportMissingTypeStubsacross unprepared packages. Use thestrictpath list instead, keepingbasic(orstandard) as the floor so nothing regresses below a sane baseline while you climb. - Leaving blanket per-file rule suppressions in place.
# pyright: reportUnknownMemberType=falseat the top of a strict file silently defeats the point. Treat each as a TODO and delete it once fixed; a periodicgrep -rn "# pyright:"keeps the suppression inventory honest. - Forgetting
reportMissingTypeStubsratchet. Leaving it atnonelets new stubless dependencies slip in unnoticed. Promote it toerroronce the package is clean so the next untyped import fails the build rather than passing silently. - Splitting config across two files. Keeping some settings in
[tool.pyright]inpyproject.tomlwhile apyrightconfig.jsonalso exists does not merge them — ifpyrightconfig.jsonis present,[tool.pyright]is ignored entirely. Pick one file and put everything there.
FAQ
Does pyright share mypy’s [[tool.mypy.overrides]] mechanism?
No. Pyright uses pyrightconfig.json path-based strict/include, executionEnvironments, and per-file pragmas. The mypy equivalent is covered in per-package mypy overrides.
Can I keep pyright config in pyproject.toml instead?
Yes, under [tool.pyright], but the strict include list and executionEnvironments are most readable in a dedicated pyrightconfig.json. The two are mutually exclusive — pyright ignores [tool.pyright] if a pyrightconfig.json exists.