Running mypy Only on Changed Files

TL;DR

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

Changed-files mypy is unsound Editing the signature of 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.

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/checkout defaults to depth 1; the PR base SHA isn’t present, so git diff fails. Set fetch-depth: 0.
  • Renames and __init__.py edits. Touching a package __init__.py can 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 main job (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.

Back to GitHub Actions Type Checking