Python Typing
Stand up static type checking in the user's Python package with a strictness
strategy the team can defend — one checker, pinned, configured in
pyproject.toml, per-module escape hatches tracked as debt, a ratchet toward
strict, and (for libraries) a py.typed marker that verifiably survives into
the built wheel. Also covers fixing the errors the checker then reports.
When NOT to use
- Ruff lint or format failures — including the ANN annotation-style rules,
which are lint, not type checking (the python-lint skill, if installed).
- A failing test suite or pytest setup (python-testing).
- Runtime exceptions — a
TypeError raised at runtime is a bug to debug
directly, not a checker-configuration task.
- The CI workflow YAML that runs the checker (python-ci) or pre-commit wiring
(python-precommit) — this skill hands the working command off to those.
- Packaging metadata, build backends, or wheel building beyond the
py.typed
marker itself (python-packaging).
Workflow
1. Survey before choosing anything
- Layout:
src/ vs flat; the import-package name; library (published, has
consumers) vs application.
- Lowest supported Python from
requires-python — the checker must target it,
not the interpreter you happen to run.
- Framework deps that need checker plugins:
grep -E "django|sqlalchemy|pydantic" pyproject.toml.
Plugins exist for mypy only; this can decide the whole checker choice.
- Existing config (
[tool.mypy], [tool.pyright], mypy.ini,
pyrightconfig.json) — extend it, don't silently replace it.
- New project vs legacy codebase (rough annotation coverage) — decides
strict-from-day-one vs ratchet in step 4.
2. Pick ONE checker — the race is unsettled
The type-checker field is a live, unsettled race (mypy, pyright/basedpyright,
ty, Pyrefly, zuban). Do not present any of them as the obvious winner, and do
not quote speed multipliers or conformance percentages — the circulating
numbers are single-source and partly refuted. Decision rules that hold up:
| Situation |
Pick |
| Django (django-stubs) or Pydantic plugin-dependent stack |
mypy + the framework's plugin (SQLAlchemy 2.x is natively typed — its mypy plugin is deprecated and stopped at mypy 1.10) |
| New project, no plugin needs, VS Code team |
pyright (standard, then strict) |
| Non-VS-Code editors (Neovim, Helix, Cursor) or stricter defaults wanted |
basedpyright |
| uv/Ruff shop that tolerates beta churn |
pilot ty locally; keep mypy or pyright as the CI gate |
| Multi-million-LOC monorepo |
evaluate Pyrefly |
Commit to one primary checker as the CI gate. Using pyright in the editor
(Pylance) with a different CI checker is normal; running two checkers as
gates means maintaining parallel suppression comments (gotcha 5). Details and
trade-offs: references/checker-selection.md.
3. Install pinned, run via uv
Pin the exact version — checker releases routinely add new diagnostics, so an
unpinned checker breaks CI on unrelated PRs. Substitute the current release
from PyPI for the pins shown:
uv add --dev "mypy==2.3.1" # or: "pyright==1.1.413" (PyPI wrapper, bundles node)
uv run mypy src tests # or: uv run pyright
Without uv (once, for reference): python -m pip install "mypy==2.3.1" then
python -m mypy src tests. For a ty pilot without touching deps:
uvx --from "ty==0.0.80" ty check src (again, substitute the current pin; ty is still pre-1.0).
4. Configure in pyproject.toml
mypy and pyright read different tables, so both can coexist while migrating.
mypy — and note strict = true is what makes mypy check unannotated code at
all (gotcha 1):
[tool.mypy]
python_version = "3.10" # lowest supported, from requires-python
strict = true
warn_unused_ignores = true
# plugins = ["pydantic.mypy"] # only if pydantic is a dependency
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"] # third-party deps that ship no types
ignore_missing_imports = true
pyright / basedpyright (use [tool.basedpyright] for the fork):
[tool.pyright]
include = ["src", "tests"]
exclude = ["**/legacy"] # added on top of pyright's defaults (node_modules, __pycache__, .*)
typeCheckingMode = "standard" # "strict" for new projects
pythonVersion = "3.10"
ty reads [tool.ty] in pyproject.toml (or ty.toml); config surface is
small and beta — check uv run ty --help rather than trusting stale examples.
5. Set strictness — day one or ratchet, never big-bang retrofit
- New project:
strict = true / typeCheckingMode = "strict" from the
first commit. Retrofitting strictness later is what generates contributor
friction and hundred-error floods.
- Legacy codebase: ratchet. Global strict with per-module exemptions that
only ever shrink:
[tool.mypy]
strict = true
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"] # exempt, migrate module-by-module
ignore_errors = true
pyright's equivalent is a strict = ["src/myapp/core"] array of strict
islands over a standard base. Full staged rollout, both checkers:
references/strictness-ratchet.md.
- Record the strategy where the team will see it (CONTRIBUTING or README): the
chosen checker, the run command, and the rule that exemptions only shrink.
6. Ship py.typed and verify the wheel (libraries)
Without a py.typed marker, PEP 561 says checkers treat every import from
your library as Any — annotations exist in source but are invisible to
consumers, silently.
touch src/<pkg>/py.typed
Build backends differ on whether non-.py files get packaged (setuptools
needs [tool.setuptools.package-data]; hatchling usually includes it), so
never trust the source tree — verify the artifact:
uv build
python3 "${CLAUDE_SKILL_DIR}/scripts/check_py_typed.py" --package <pkg>
The script exits non-zero if py.typed is missing from the source package,
the newest wheel, or the newest sdist. Backend-by-backend config and stub
distribution options: references/py-typed-distribution.md.
7. Fix the reported errors
Triage in this order:
- Real bugs first —
Optional misuse, wrong return types, unreachable
branches. These are the payoff; don't suppress them.
- Untyped third-party imports — install the
types-* stub package if one
exists and pin it (stubs version independently of their library). Only
fall back to a per-module ignore_missing_imports override — never the
global flag.
- Genuine false positives — suppress with an error-code-scoped comment,
# type: ignore[arg-type] (mypy) or # pyright: ignore[reportArgumentType],
and keep warn_unused_ignores (mypy) / reportUnnecessaryTypeIgnoreComment
(pyright) on so stale ignores fail the build.
- Never fix an error by weakening global config.
8. Hand off
The type-check command is now the contract: the python-ci skill (if installed)
turns it into a CI job; python-precommit wires it into hooks. Not this skill's
job — stop at a locally passing command.
Output spec
Done means all of these hold:
- Exactly one primary checker, pinned in dev dependencies, configured in
pyproject.toml.
uv run <checker> ... exits 0 over src (and tests, unless deliberately
excluded with a written reason).
- Strictness decision recorded: strict, or the current ratchet stage plus the
exemption list tracked as debt.
- For a library:
py.typed present in the package and
scripts/check_py_typed.py passes against a freshly built wheel.
- Every suppression comment is error-code-scoped; unused-ignore detection is
enabled.
- The run command is documented where the team will find it.
Failure modes & gotchas
- mypy green ≠ checked. By default mypy skips unannotated function
bodies; a "no errors" run can be ignoring most of the codebase. Set
check_untyped_defs = true (or strict) before trusting any green run.
- Global strict + per-module downgrades don't compose. Long-standing
issues in both tools (mypy #11401, pyright #601): you cannot cleanly
downgrade individual strict rules per module. What works: global strict
with per-module
ignore_errors (mypy) or a strict directory array over a
standard base (pyright).
- pyright floods legacy code harder than mypy. It was already checking
unannotated code, so jumping straight to
strict surfaces far more errors
at once — start at standard on existing codebases.
- py.typed missing from the wheel is the classic invisible failure: the
marker sits in git, the backend drops it from the artifact, and every
downstream user silently gets
Any. Always verify the built wheel, not the
source tree (step 6).
- Two checkers, double suppressions. The engines disagree (kwargs typing,
__new__ inference, union widening), so the same line can need both
# type: ignore[...] and # pyright: ignore[...]. Accept that cost
knowingly or stay with one gate.
- Strict is not portable. mypy strict, pyright strict, basedpyright's
"recommended" preset, and ty/Pyrefly rules are all different contracts —
zero errors on one implies nothing about the others.
- Pydantic friction. Checkers can't see through
before/wrap
validators, so constructors called with pre-validation input types raise
false positives. Options with trade-offs: mypy + pydantic.mypy plugin,
rule-scoped ignores on model modules, or (bleeding-edge) Pyrefly's
experimental Pydantic integration. Separately — a statically clean model is
not runtime-safe: Pydantic coerces by default ("30" becomes 30) unless
its own strict mode is enabled.
- ty/Pyrefly maturity. No plugin system (Django/SQLAlchemy/Pydantic
plugin stacks can't migrate), beta-grade churn, and migrating off pyright
can lose diagnostics it used to catch. ty's "gradual guarantee" (removing
or loosening an annotation never introduces new errors — adding precision
may surface real ones) makes it a pleasant incremental pilot — as an editor/local tool, not yet an OSS package's CI
gate.
- pyright
exclude is additive. Custom entries are added on top of the
defaults (**/node_modules, **/__pycache__, **/.*), and the defaults take
precedence — so you cannot un-exclude a dot-directory by listing it in
include; move the code instead.
- Ruff is not a type checker. Its ANN rules police annotation style;
they catch zero type errors. A passing lint run says nothing here.
- Checker upgrades flip defaults. mypy 2.0 (2026-05) turned
--local-partial-types and --strict-bytes on by default, changed
--allow-redefinition semantics, dropped --python-version 3.9, and
added experimental parallel checking (--num-workers N) — a routine bump
can newly break CI, which is why step 3 pins exact versions and upgrades
deliberately.
Bundled resources
- references/checker-selection.md — the
contender field, comparison table, decision guide, hybrid editor/CI pattern.
- references/strictness-ratchet.md —
staged strict-mode rollout with full configs for mypy and pyright.
- references/py-typed-distribution.md —
PEP 561 distribution options, per-backend packaging config, verification.
scripts/check_py_typed.py — verifies py.typed in the source package and
inside built wheel/sdist artifacts; read-only, non-zero exit on failure.
1---2name: python-typing3description: Sets up static type checking for a Python package — choosing mypy, pyright, basedpyright or ty, strict configuration, per-module overrides, shipping py.typed — and fixes type-check errors. Use when the user says 'add type checking', 'set up mypy', 'pyright errors', 'make this package typed'. Not for lint or format failures, failing tests, runtime bugs, or the CI workflow that runs the checker.4license: MIT5---67# Python Typing89Stand up static type checking in the user's Python package with a strictness10strategy the team can defend — one checker, pinned, configured in11`pyproject.toml`, per-module escape hatches tracked as debt, a ratchet toward12strict, and (for libraries) a `py.typed` marker that verifiably survives into13the built wheel. Also covers fixing the errors the checker then reports.1415## When NOT to use1617- Ruff lint or format failures — including the ANN annotation-style rules,18 which are lint, not type checking (the python-lint skill, if installed).19- A failing test suite or pytest setup (python-testing).20- Runtime exceptions — a `TypeError` raised at runtime is a bug to debug21 directly, not a checker-configuration task.22- The CI workflow YAML that runs the checker (python-ci) or pre-commit wiring23 (python-precommit) — this skill hands the working command off to those.24- Packaging metadata, build backends, or wheel building beyond the `py.typed`25 marker itself (python-packaging).2627## Workflow2829### 1. Survey before choosing anything3031- Layout: `src/` vs flat; the import-package name; library (published, has32 consumers) vs application.33- Lowest supported Python from `requires-python` — the checker must target it,34 not the interpreter you happen to run.35- Framework deps that need checker *plugins*: `grep -E "django|sqlalchemy|pydantic" pyproject.toml`.36 Plugins exist for mypy only; this can decide the whole checker choice.37- Existing config (`[tool.mypy]`, `[tool.pyright]`, `mypy.ini`,38 `pyrightconfig.json`) — extend it, don't silently replace it.39- New project vs legacy codebase (rough annotation coverage) — decides40 strict-from-day-one vs ratchet in step 4.4142### 2. Pick ONE checker — the race is unsettled4344The type-checker field is a live, unsettled race (mypy, pyright/basedpyright,45ty, Pyrefly, zuban). Do not present any of them as the obvious winner, and do46not quote speed multipliers or conformance percentages — the circulating47numbers are single-source and partly refuted. Decision rules that hold up:4849| Situation | Pick |50| --- | --- |51| Django (django-stubs) or Pydantic plugin-dependent stack | mypy + the framework's plugin (SQLAlchemy 2.x is natively typed — its mypy plugin is deprecated and stopped at mypy 1.10) |52| New project, no plugin needs, VS Code team | pyright (`standard`, then `strict`) |53| Non-VS-Code editors (Neovim, Helix, Cursor) or stricter defaults wanted | basedpyright |54| uv/Ruff shop that tolerates beta churn | pilot ty locally; keep mypy or pyright as the CI gate |55| Multi-million-LOC monorepo | evaluate Pyrefly |5657Commit to **one primary checker as the CI gate**. Using pyright in the editor58(Pylance) with a different CI checker is normal; running two checkers *as59gates* means maintaining parallel suppression comments (gotcha 5). Details and60trade-offs: [references/checker-selection.md](references/checker-selection.md).6162### 3. Install pinned, run via uv6364Pin the exact version — checker releases routinely add new diagnostics, so an65unpinned checker breaks CI on unrelated PRs. Substitute the current release66from PyPI for the pins shown:6768```bash69uv add --dev "mypy==2.3.1" # or: "pyright==1.1.413" (PyPI wrapper, bundles node)70uv run mypy src tests # or: uv run pyright71```7273Without uv (once, for reference): `python -m pip install "mypy==2.3.1"` then74`python -m mypy src tests`. For a ty pilot without touching deps:75`uvx --from "ty==0.0.80" ty check src` (again, substitute the current pin; ty is still pre-1.0).7677### 4. Configure in pyproject.toml7879mypy and pyright read different tables, so both can coexist while migrating.80mypy — and note `strict = true` is what makes mypy check unannotated code at81all (gotcha 1):8283```toml84[tool.mypy]85python_version = "3.10" # lowest supported, from requires-python86strict = true87warn_unused_ignores = true88# plugins = ["pydantic.mypy"] # only if pydantic is a dependency8990[[tool.mypy.overrides]]91module = ["some_untyped_lib.*"] # third-party deps that ship no types92ignore_missing_imports = true93```9495pyright / basedpyright (use `[tool.basedpyright]` for the fork):9697```toml98[tool.pyright]99include = ["src", "tests"]100exclude = ["**/legacy"] # added on top of pyright's defaults (node_modules, __pycache__, .*)101typeCheckingMode = "standard" # "strict" for new projects102pythonVersion = "3.10"103```104105ty reads `[tool.ty]` in `pyproject.toml` (or `ty.toml`); config surface is106small and beta — check `uv run ty --help` rather than trusting stale examples.107108### 5. Set strictness — day one or ratchet, never big-bang retrofit109110- **New project**: `strict = true` / `typeCheckingMode = "strict"` from the111 first commit. Retrofitting strictness later is what generates contributor112 friction and hundred-error floods.113- **Legacy codebase**: ratchet. Global strict with per-module exemptions that114 only ever shrink:115116```toml117[tool.mypy]118strict = true119120[[tool.mypy.overrides]]121module = ["myapp.legacy.*"] # exempt, migrate module-by-module122ignore_errors = true123```124125 pyright's equivalent is a `strict = ["src/myapp/core"]` array of strict126 islands over a `standard` base. Full staged rollout, both checkers:127 [references/strictness-ratchet.md](references/strictness-ratchet.md).128- Record the strategy where the team will see it (CONTRIBUTING or README): the129 chosen checker, the run command, and the rule that exemptions only shrink.130131### 6. Ship py.typed and verify the wheel (libraries)132133Without a `py.typed` marker, PEP 561 says checkers treat every import from134your library as `Any` — annotations exist in source but are invisible to135consumers, silently.136137```bash138touch src/<pkg>/py.typed139```140141Build backends differ on whether non-`.py` files get packaged (setuptools142needs `[tool.setuptools.package-data]`; hatchling usually includes it), so143never trust the source tree — verify the artifact:144145```bash146uv build147python3 "${CLAUDE_SKILL_DIR}/scripts/check_py_typed.py" --package <pkg>148```149150The script exits non-zero if `py.typed` is missing from the source package,151the newest wheel, or the newest sdist. Backend-by-backend config and stub152distribution options: [references/py-typed-distribution.md](references/py-typed-distribution.md).153154### 7. Fix the reported errors155156Triage in this order:1571581. **Real bugs first** — `Optional` misuse, wrong return types, unreachable159 branches. These are the payoff; don't suppress them.1602. **Untyped third-party imports** — install the `types-*` stub package if one161 exists and pin it (stubs version independently of their library). Only162 fall back to a per-module `ignore_missing_imports` override — never the163 global flag.1643. **Genuine false positives** — suppress with an error-code-scoped comment,165 `# type: ignore[arg-type]` (mypy) or `# pyright: ignore[reportArgumentType]`,166 and keep `warn_unused_ignores` (mypy) / `reportUnnecessaryTypeIgnoreComment`167 (pyright) on so stale ignores fail the build.1684. Never fix an error by weakening global config.169170### 8. Hand off171172The type-check command is now the contract: the python-ci skill (if installed)173turns it into a CI job; python-precommit wires it into hooks. Not this skill's174job — stop at a locally passing command.175176## Output spec177178Done means all of these hold:179180- Exactly one primary checker, pinned in dev dependencies, configured in181 `pyproject.toml`.182- `uv run <checker> ...` exits 0 over `src` (and `tests`, unless deliberately183 excluded with a written reason).184- Strictness decision recorded: strict, or the current ratchet stage plus the185 exemption list tracked as debt.186- For a library: `py.typed` present in the package **and**187 `scripts/check_py_typed.py` passes against a freshly built wheel.188- Every suppression comment is error-code-scoped; unused-ignore detection is189 enabled.190- The run command is documented where the team will find it.191192## Failure modes & gotchas1931941. **mypy green ≠ checked.** By default mypy skips unannotated function195 bodies; a "no errors" run can be ignoring most of the codebase. Set196 `check_untyped_defs = true` (or `strict`) before trusting any green run.1972. **Global strict + per-module downgrades don't compose.** Long-standing198 issues in both tools (mypy #11401, pyright #601): you cannot cleanly199 downgrade individual strict rules per module. What works: global strict200 with per-module `ignore_errors` (mypy) or a `strict` directory array over a201 `standard` base (pyright).2023. **pyright floods legacy code harder than mypy.** It was already checking203 unannotated code, so jumping straight to `strict` surfaces far more errors204 at once — start at `standard` on existing codebases.2054. **py.typed missing from the wheel** is the classic invisible failure: the206 marker sits in git, the backend drops it from the artifact, and every207 downstream user silently gets `Any`. Always verify the built wheel, not the208 source tree (step 6).2095. **Two checkers, double suppressions.** The engines disagree (kwargs typing,210 `__new__` inference, union widening), so the same line can need both211 `# type: ignore[...]` and `# pyright: ignore[...]`. Accept that cost212 knowingly or stay with one gate.2136. **Strict is not portable.** mypy strict, pyright strict, basedpyright's214 "recommended" preset, and ty/Pyrefly rules are all different contracts —215 zero errors on one implies nothing about the others.2167. **Pydantic friction.** Checkers can't see through `before`/`wrap`217 validators, so constructors called with pre-validation input types raise218 false positives. Options with trade-offs: mypy + `pydantic.mypy` plugin,219 rule-scoped ignores on model modules, or (bleeding-edge) Pyrefly's220 experimental Pydantic integration. Separately — a statically clean model is221 not runtime-safe: Pydantic coerces by default (`"30"` becomes `30`) unless222 its own strict mode is enabled.2238. **ty/Pyrefly maturity.** No plugin system (Django/SQLAlchemy/Pydantic224 plugin stacks can't migrate), beta-grade churn, and migrating off pyright225 can *lose* diagnostics it used to catch. ty's "gradual guarantee" (removing226 or loosening an annotation never introduces new errors — adding precision227 may surface real ones) makes it a pleasant incremental pilot — as an editor/local tool, not yet an OSS package's CI228 gate.2299. **pyright `exclude` is additive.** Custom entries are added on top of the230 defaults (`**/node_modules`, `**/__pycache__`, `**/.*`), and the defaults take231 precedence — so you cannot un-exclude a dot-directory by listing it in232 `include`; move the code instead.23310. **Ruff is not a type checker.** Its ANN rules police annotation *style*;234 they catch zero type errors. A passing lint run says nothing here.23511. **Checker upgrades flip defaults.** mypy 2.0 (2026-05) turned236 `--local-partial-types` and `--strict-bytes` on by default, changed237 `--allow-redefinition` semantics, dropped `--python-version 3.9`, and238 added experimental parallel checking (`--num-workers N`) — a routine bump239 can newly break CI, which is why step 3 pins exact versions and upgrades240 deliberately.241242## Bundled resources243244- [references/checker-selection.md](references/checker-selection.md) — the245 contender field, comparison table, decision guide, hybrid editor/CI pattern.246- [references/strictness-ratchet.md](references/strictness-ratchet.md) —247 staged strict-mode rollout with full configs for mypy and pyright.248- [references/py-typed-distribution.md](references/py-typed-distribution.md) —249 PEP 561 distribution options, per-backend packaging config, verification.250- `scripts/check_py_typed.py` — verifies `py.typed` in the source package and251 inside built wheel/sdist artifacts; read-only, non-zero exit on failure.