How to gradually adopt type checking in an existing Python project
Running a type checker on an established codebase for the first time produces hundreds or thousands of errors. That wall of red discourages adoption before it starts. The practical path is to check new code strictly while ignoring legacy issues, then shrink the ignore list over time.
This guide uses mypy, but the same strategy applies to pyright and ty.
Add mypy to the project
uv add --dev mypyRun mypy and capture the baseline
Run mypy against the full codebase to see what you’re working with:
uv run mypy .The output will likely contain errors across many files. That’s expected.
Bootstrap annotations with a codemod (optional)
On a large unannotated codebase, manually adding return types and parameter annotations to every function is the slow path. A codemod that writes annotations from static analysis or runtime traces can compress weeks of work into a reviewable diff.
pyrefly infer is the static option: it reads call sites and writes annotations directly into source files, no runtime instrumentation required. It works whether you ultimately use Pyrefly, mypy, ty, or pyright as your checker, since the output is plain Python annotations.
Run it on a single directory at a time, review the diff, and commit before continuing. Then return to this guide to configure overrides and start on the modules that the codemod left untouched.
Suppress existing errors per-module
Rather than fixing everything upfront, configure mypy to ignore errors in modules that haven’t been typed yet. Add a section to pyproject.toml that silences errors in legacy modules:
[tool.mypy]
python_version = "3.12"
strict = false
# Enforce type checking on new or migrated modules
[[tool.mypy.overrides]]
module = ["mypackage.api.*", "mypackage.models.*"]
disallow_untyped_defs = true
warn_return_any = true
# Silence errors in legacy modules for now
[[tool.mypy.overrides]]
module = ["mypackage.legacy.*", "mypackage.utils.*"]
ignore_errors = trueThis approach has two lists: modules that are checked strictly and modules that are ignored. As code gets typed, move modules from the second list to the first.
Start strict on new code
Set a rule for the team: all new files and modules must pass type checking. Enforce this by adding new module paths to the strict overrides section as they’re created.
A useful pattern is to invert the approach once most code is typed. Instead of listing which modules to check, list which modules to skip:
[tool.mypy]
strict = true
# Only these legacy modules are still exempt
[[tool.mypy.overrides]]
module = ["mypackage.legacy_parser", "mypackage.old_cli"]
ignore_errors = trueAdd type checking to CI
Add mypy to the test suite so type errors block merges:
uv run mypy .This works alongside uv run pytest and uv run ruff check . in a CI pipeline. See Setting up GitHub Actions with uv for CI configuration details.
Chip away at untyped modules
Pick one module at a time. Remove it from the ignore list, fix the type errors, and commit. Smaller modules go faster and build momentum.
Common fixes when typing a module for the first time:
- Add return type annotations to functions
- Replace bare
dictandlistwithdict[str, int]orlist[str] - Add
from __future__ import annotationsto use newer annotation syntax on older Python versions - Install type stubs for third-party libraries (
uv add --dev types-requestsfor therequestspackage, for example)
Use per-file ignores for stubborn errors
Some errors in a mostly-typed module aren’t worth fixing immediately. Use inline comments to suppress individual lines:
result = some_untyped_library.process(data) # type: ignore[no-untyped-call]Always include the error code in brackets. Bare # type: ignore comments mask real issues that appear later.
Use Pyrefly’s first-party adoption tools
Pyrefly 1.0 ships two adoption helpers that match the strategy in this guide and remove the need to roll your own tooling.
pyrefly coverage report writes a JSON report of annotation completeness and type completeness per function, class, and module. Commit the report under .pyrefly/ or send it to a dashboard to track how the typed surface grows over time. See the coverage report docs.
Baseline files replace inline # pyrefly: ignore comments with a snapshot of the current error set:
uv run pyrefly check --baseline=.pyrefly/baseline.json --update-baselineSubsequent pyrefly check --baseline=.pyrefly/baseline.json runs report only errors not present in the baseline, so new code is checked strictly while legacy errors stay silent in the file rather than scattered across source. The mode is experimental in 1.0; see Pyrefly’s baseline files docs.
Learn More
- How do mypy, pyright, and ty compare?
- How to try the ty type checker
- How to use ty in CI
- How to configure Claude Code with a Python type checker
- How to migrate from mypy to ty
- mypy reference
- pyright reference
- ty reference
- Pyrefly reference
- Pyrefly coverage report docs
- Pyrefly baseline files docs
- mypy existing code documentation