Running mypy Only on Changed Files
Pass only the files changed in a PR to mypy — via tj-actions/changed-files or git diff — to cut PR feedback time. But this is unsound for whole-program inference: an edit to one file can introduce a [arg-type] error in a file you didn’t touch and didn’t pass to mypy. The safe pattern is changed-files on pull requests, full mypy . on main.
mypy performs whole-program inference: the type of a call site depends on definitions that may live in entirely different modules. Running mypy in GitHub Actions against the full tree on every PR is correct but can be slow on large repositories. A common optimization is to scope the run to just the files a pull request changed. It genuinely speeds up feedback — and it genuinely trades away soundness. This page shows how to do it, exactly where it breaks, and the hybrid that keeps the gate trustworthy.
Step 1: collect the changed Python files
tj-actions/changed-files returns the set of files touched by the PR. Filter to .py/.pyi.
# .github/workflows/typecheck.yml — tj-actions/changed-files@v45
- name: Get changed Python files
id: changed
uses: tj-actions/changed-files@v45
with:
files: |
**/*.py
**/*.pyi
The plain git diff equivalent, with no third-party action:
# .github/workflows/typecheck.yml — git diff against the PR base
- name: Get changed Python files
id: changed
run: |
base="${{ github.event.pull_request.base.sha }}"
files=$(git diff --name-only --diff-filter=d "$base"...HEAD -- '*.py' '*.pyi')
echo "files=$(echo "$files" | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
--diff-filter=d drops deleted files so you don’t hand mypy a path that no longer exists (which would itself error).
Step 2: run mypy on just those files
# .github/workflows/typecheck.yml — scoped mypy, mypy 1.x
- name: Type-check changed files
if: steps.changed.outputs.any_changed == 'true'
run: mypy --strict --show-error-codes ${{ steps.changed.outputs.all_changed_files }}
What the analyzer sees: mypy loads the files you passed, follows their imports to resolve types, but only reports errors located in the passed files. Errors in imported-but-unchanged modules are computed and then suppressed from the output — which is the root of the soundness gap.
Step 3: understand the unsoundness
parse_response() can break every caller. If those callers weren't in the diff, you didn't pass them to mypy, so their new [arg-type] or [call-arg] errors are never reported. The PR goes green while main is now broken. Scoped runs catch errors in changed files, not errors caused by changed files.
Concretely:
# service/api.py (changed in this PR)
def parse_response(payload: dict[str, int]) -> int: # was dict[str, str]
return sum(payload.values())
# service/handlers.py (NOT in the diff — never passed to mypy)
from service.api import parse_response
parse_response({"count": "12"}) # mypy error: [arg-type] — but only on a full run
A changed-files run that passes only service/api.py reports nothing. mypy . on the full tree reports [arg-type] in handlers.py. Same code, different verdict — purely because of which files were named.
Step 4: the recommended hybrid
Run the fast, scoped check on pull requests for quick feedback, and a full, sound check on the branch you actually protect. The main job is the real gate; the PR job is an early-warning convenience.
# .github/workflows/typecheck.yml — hybrid: scoped on PR, full on main
jobs:
changed-files-mypy:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history so the diff base resolves
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install -e ".[dev]"
- id: changed
uses: tj-actions/changed-files@v45
with: { files: "**/*.py" }
- if: steps.changed.outputs.any_changed == 'true'
run: mypy --strict ${{ steps.changed.outputs.all_changed_files }}
full-mypy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install -e ".[dev]"
- run: mypy --strict . # whole-program, sound gate
A stricter variant keeps mypy . on every PR too but relies on a restored .mypy_cache to make the full run cheap — often the better answer, since incremental caching frequently closes the speed gap without giving up soundness.
Edge cases
- Shallow checkout breaks the diff.
actions/checkoutdefaults to depth 1; the PR base SHA isn’t present, sogit difffails. Setfetch-depth: 0. - Renames and
__init__.pyedits. Touching a package__init__.pycan change re-exports for many modules, none of which are in the diff. Scoped runs are blind to this; full runs catch it. - Deleted modules. A removed file may leave a dangling import elsewhere —
[import]/[import-not-found]. The deletion appears in the diff but the now-broken importer does not, so scoped mypy misses it.
Common mistakes
- Treating the scoped PR job as the gate. Branch protection should require the full
mainjob (or a cached full PR run), not the changed-files job. Otherwise cross-file[arg-type]regressions merge silently. - Forgetting
--diff-filter=d. Passing a deleted path makes mypy emit[misc]“cannot find module” noise unrelated to the actual change. - Scoping pyright the same way. Pyright also does whole-program analysis; scoping it to changed files has the identical unsoundness and no
.mypy_cache-style speedup to fall back on.
FAQ
Can I make changed-files mypy sound by adding --follow-imports?
No. --follow-imports controls whether imported modules are analyzed, but mypy still only reports errors in the files you named. The reverse dependency — callers of what you changed — is never reached unless you pass those callers too.
Is caching a better speedup than scoping?
Usually yes. A restored .mypy_cache makes a full, sound mypy . nearly as fast as a scoped run on typical PRs, with none of the soundness loss. Reach for changed-files scoping only when even the cached full run is too slow.