Enabling Pyright Strict Mode Incrementally

TL;DR

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.

Pyright mode ladder: off, basic, strict Three ascending steps show off enabling the fewest rules and strict enabling the most. More report* rules enabled → off syntax + a few always-on basic + reportMissingImports + reportOptionalMemberAccess + reportArgumentType / ReturnType strict + reportUnknown* family + reportMissingTypeStubs + reportPrivateUsage + reportUntyped* decorators
Each mode is a preset over the same rule set; strict simply enables the most.

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.

Before and after a per-file # pyright: strict pragma One file is promoted to strict by a pragma while the rest of its package remains basic. Before After packages/api (basic) parser.py — basic client.py — basic models.py — basic # pyright: strict packages/api (basic) parser.py — strict client.py — basic models.py — basic
The pragma promotes one file; its siblings keep the package default.
# 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.

executionEnvironments scoping rules by directory subtree The strict top level applies except where a more specific root overrides it. top level typeCheckingMode: strict root: packages/core no overrides inherits strict reportUnknown* = error no matching root uses top-level strict settings root: packages/legacy_etl reportUnknownMemberType: none reportMissingTypeStubs: none + extraPaths
Longest matching root wins; unnamed rules inherit the top-level mode.
// 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.

Pragma beats config, narrowest scope wins Resolution order is: an in-file # 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.

The basic-to-strict ratchet loop Each package cycles through add-to-strict, run, fix, and suppression removal. add path to strict list run pyright, read diagnostics fix or per-rule pragma-suppress delete each suppression next package
Repeat per package until every path is strict with no suppressions left.
// 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.

Runtime vs static analysis A # 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: basic pragma wins even if the file’s directory is in the strict list. 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 (a types-* distribution) with no runtime package present triggers this; it’s informational, distinct from reportMissingImports, and usually safe to leave at warning. It means “I found type information but no source to run” — common in a lint-only CI job that installs types-* packages but not their runtime counterparts.
Which missing-import diagnostic pyright emits Three yes-or-no questions map an import problem to one of three report rules. Module resolvable at all? (any file or stub found?) no yes reportMissingImports error — nothing found Runtime package present? (not stub-only) no yes reportMissingModuleSource warning — stub with no runtime (types-* only) reportMissingTypeStubs strict — code but no py.typed / stubs
Three symptoms, three distinct rules — don't conflate them.
  • extraPaths and namespace packages. Strict mode surfaces import resolution gaps that basic tolerated. If a namespace package resolves at runtime but pyright reports reportMissingImports, add its root to extraPaths in the relevant executionEnvironments entry.
  • Stale suppression comments. reportUnnecessaryTypeIgnoreComment flags # type: ignore and # pyright: ignore comments that no longer suppress anything — a useful ratchet companion once a package is clean. Note it defaults to "none" even under strict, so you must enable it explicitly, and it can clash with a shared codebase where mypy still needs the # type: ignore that 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-expr and --disallow-untyped-defs). A file that passes mypy --strict can still emit dozens of pyright-strict reportUnknownMemberType errors, 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 with reportUnknownMemberType, reportUnknownArgumentType, and reportMissingTypeStubs across unprepared packages. Use the strict path list instead, keeping basic (or standard) as the floor so nothing regresses below a sane baseline while you climb.
  • Leaving blanket per-file rule suppressions in place. # pyright: reportUnknownMemberType=false at the top of a strict file silently defeats the point. Treat each as a TODO and delete it once fixed; a periodic grep -rn "# pyright:" keeps the suppression inventory honest.
  • Forgetting reportMissingTypeStubs ratchet. Leaving it at none lets new stubless dependencies slip in unnoticed. Promote it to error once 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] in pyproject.toml while a pyrightconfig.json also exists does not merge them — if pyrightconfig.json is present, [tool.pyright] is ignored entirely. Pick one file and put everything there.
Common strict-rollout mistakes and their fixes Four antipatterns are matched to the correct approach. Mistake Fix global typeCheckingMode: strict day one curated strict path list, basic floor blanket # pyright: reportX=false left in treat as TODO, delete when fixed reportMissingTypeStubs left at none ratchet to warning, then error config split: config.json + [tool.pyright] one file — pyrightconfig.json wins
Each antipattern on the left has a one-line correction on the right.

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.

Back to Monorepo Incremental Typing