How to gradually adopt type checking in an existing Python project
Add type checking to an existing project by keeping legacy exceptions explicit and requiring new or migrated modules to pass. This recipe uses mypy; Pyrefly offers a baseline-file alternative.
Install mypy and check the current code
uv add --dev mypy
uv run mypy .The --dev flag marks mypy as a development dependencyA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag.
, keeping it out of production installs. uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first.
Record the reported errors before editing configuration. A clean default run does not mean all code is checked: mypy skips bodies of completely unannotated functions by default.
Check bodies while requiring annotations in selected modules
Adapt the module names in this configuration to the project:
[tool.mypy]
check_untyped_defs = true
[[tool.mypy.overrides]]
module = ["mypackage.api", "mypackage.api.*", "mypackage.models", "mypackage.models.*"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_calls = true
warn_return_any = true
[[tool.mypy.overrides]]
module = ["mypackage.legacy", "mypackage.legacy.*", "mypackage.utils", "mypackage.utils.*"]
ignore_errors = truecheck_untyped_defs checks operations whose types are known inside unannotated functions; their parameters remain Any. The selected modules also require annotations, reject untyped calls from typed functions, and reject returning Any from typed functions. Use individual flags in overrides; strict = true belongs in the global table because it can affect modules outside an override. ignore_errors silences diagnostics in the named legacy modules while leaving them available to import analysis.
Require new modules to pass in CI
Add new module names to the annotation-requiring override, then run the same command locally and after installing locked dependencies in CI:
uv run mypy .A clean run prints Success: no issues found ... and exits 0; reported type errors exit 1. See setting up GitHub Actions with uv for the workflow setup.
Move one legacy module into the checked set
Remove a module from the ignored override and add it to the annotation-requiring override. Add annotations, install missing stubs, and fix the resulting diagnostics before repeating the process.
For a small remaining exception, use a specific code such as # type: ignore[no-untyped-call] on the reported line. Enable warn_unused_ignores = true to find stale suppressions. Avoid bare ignores that hide unrelated future errors.
Pyrefly’s annotation inference command is an optional aid for producing annotations. Review inferred annotations as code changes; they do not establish that every caller is correctly typed.
Enable strict mode globally when the exception list is small
Replace the opt-in annotation policy with global strict mode, retaining only the remaining legacy exceptions:
[tool.mypy]
strict = true
[[tool.mypy.overrides]]
module = ["mypackage.legacy_parser", "mypackage.old_cli"]
ignore_errors = trueContinue shrinking the exception list. For individual strict flags, see how to configure mypy strict mode.
Use a Pyrefly baseline instead of module exemptions
For a project using Pyrefly, install it and initialize a committed configuration before collecting diagnostics:
uv add --dev pyrefly
uv run pyrefly init --non-interactiveThe initializer writes configuration and runs an initial check. Add this setting to pyrefly.toml (or to [tool.pyrefly] if configuration is in pyproject.toml):
baseline-matching-mode = "column-ordered"This mode limits baseline suppression to the recorded sequence, so additional occurrences are reported. The default mode allows one entry to hide multiple diagnostics with the same file, error kind, and column. Collect and verify the backlog:
uv run pyrefly check --baseline=pyrefly-baseline.json --update-baseline
uv run pyrefly check --baseline=pyrefly-baseline.jsonThe update command writes a baseline and can exit 1 while displaying the existing errors. The following check suppresses matching entries and fails on unmatched diagnostics. Keep --update-baseline out of CI, where it would accept the errors CI should catch.
The experimental baseline format records existing diagnostics, not annotation coverage. Keep the same baseline flag in CI or set baseline = "pyrefly-baseline.json" in pyrefly.toml. Commit the checker lockfile, configuration, and baseline together.
To measure annotation coverage separately, run:
uv run pyrefly coverage report . > type-coverage.jsonThis writes JSON with per-module counts and aggregate coverage and strict_coverage percentages. Coverage includes annotations containing Any; strict coverage excludes them. Neither metric proves that the annotations are correct.