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 action exposes several outputs. steps.changed.outputs.all_changed_files is a space-separated list of matching paths, any_changed is the string 'true' or 'false', and all_changed_files_count is the number of matches — useful for a quick echo in the log or for skipping the job entirely. The separator defaults to a single space, which is exactly what mypy wants on the command line; if a repository has paths containing spaces you can set separator to a newline and feed the list through xargs instead. One operational note: after the March 2025 supply-chain compromise of tj-actions/changed-files, pin third-party actions to a full-length commit SHA (uses: tj-actions/changed-files@<40-char-sha>) rather than a moving @v45 tag, and let Dependabot bump the SHA under review.

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"

The three-dot base...HEAD is deliberate. Git’s three-dot syntax diffs HEAD against the merge base of base and HEAD — the commit where the PR branch diverged — which is precisely the set GitHub renders in the “Files changed” tab. Two-dot base..HEAD diffs the current tip of the base branch directly, so if main advanced after the branch was cut, unrelated files leak into the list and mypy checks code the PR never touched. --diff-filter=d (lowercase d excludes deletions; an uppercase D would select only deletions) drops removed files so you don’t hand mypy a path that no longer exists, which would itself error.

Collecting changed Python files as a pipeline Refs feed git diff, the output is filtered to Python files, and the result is a space-separated list for mypy. From refs to a file list base...HEAD merge-base diff git diff --name-only -d filter *.py *.pyi file list space-separated
The diff is scoped to Python files, then handed to mypy as a plain argument list.

Whichever collector you use, guard the mypy step on any_changed == 'true': a docs-only or YAML-only PR produces an empty list, and invoking mypy with no arguments falls back to whatever files/mypy_path is configured (often the whole project), silently undoing the optimization. The --diff-filter=d guard and the any_changed guard together are what keep the collected list both non-empty and free of phantom paths.

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 actually does is more subtle than “check these files.” mypy builds an import graph rooted at the files you name, follows each import edge to the modules those files depend on (their callees), and type-checks the whole reachable set. Which of those followed modules get their errors printed is governed by follow_imports: the default normal reports errors in followed modules too, silent analyzes them but suppresses their output, and skip treats them as Any without checking. The decisive fact for scoping is that import edges point from caller to callee, so mypy only ever walks downstream of the changed file. The files that import what you changed — its callers — are upstream and are never added to the graph unless you pass them explicitly. That is the whole soundness gap in one sentence. --show-error-codes is on by default in mypy 1.x, so errors already print their bracketed code such as [arg-type], [call-arg], [import-not-found], or [misc]; keeping the flag explicit just documents intent.

Analyzed set versus reported set mypy analyzes the passed files plus everything they import, but reports on only the files you named. Analyzed: passed files + their imports (callees, followed downstream) Reported: the files you named api.py, service.py … callee utils.py followed, not a caller
Callers live outside the analyzed set entirely — only downstream callees are followed.

Pyright behaves the same way conceptually and differs in the details that matter for CI. You scope it by passing paths too (pyright ${{ steps.changed.outputs.all_changed_files }}), and --outputjson gives a machine-readable report you can post back to the PR. The critical operational difference is caching: pyright keeps no persistent on-disk artifact equivalent to mypy’s .mypy_cache, so every CLI invocation re-parses and re-binds the reachable graph from scratch. That means the “just cache it instead of scoping” escape hatch described below exists for mypy but not for pyright — a point that resurfaces under Common mistakes. Note too that with --strict, mypy may emit [unused-ignore] on a # type: ignore that is genuinely needed only when the full program is present, because in the narrowed graph the suppressed error never arises.

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.

The mechanism is the direction of the import graph. An import statement records that the importing module depends on the imported one; the arrow runs caller → callee. mypy, handed a changed file, walks along those arrows to resolve the types it needs, which reaches callees but never callers. A signature change is felt by callers — the modules pointing at the file you edited — and they sit on the far side of an edge mypy will not traverse. No flag reverses this within a scoped run, because mypy has no reverse-dependency index built from files it was never told exist.

The reverse dependency the scoped run cannot see handlers.py imports the changed api.py, but sits outside the diff, so its new error is never checked. diff boundary handlers.py unchanged, not in diff api.py changed, in diff imports [arg-type] here — never reported
mypy follows api.py downstream; it never walks back up the import edge to handlers.py.

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 Argument 1 to "parse_response" has incompatible type "dict[str, str]"; expected "dict[str, int]" [arg-type] in handlers.py. Same code, different verdict — purely because of which files were named. The [call-arg] variant is just as invisible: delete a parameter from parse_response, and every call site now passing that argument earns Too many arguments for "parse_response" [call-arg] — again only on a run that includes those callers. Because the checker’s answer depends on the argument list rather than on the code, the scoped job can stay green through an entire class of cross-file regressions.

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
Which mypy job runs for which event Pull requests trigger the fast scoped job; pushes to main trigger the full authoritative check. GitHub event workflow trigger event == pull_request ref == refs/heads/main Scoped mypy (changed files) fast, advisory — not the gate Full mypy . sound — branch-protection gate
The event decides the job; only the full run is a required status check.

Wire this up in the repository’s branch-protection settings so the required status check is full-mypy (or a cached full PR run), never changed-files-mypy. If the scoped job is the required check, GitHub will happily merge a PR whose cross-file [arg-type] regression the scoped run could not see. 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 mypy’s incremental mode reuses per-module .data.json cache entries and only re-checks modules whose source or dependencies changed, frequently closing the speed gap without giving up soundness. Reach for changed-files scoping only when even a warm cached mypy . is too slow, for example on a very large monorepo; see monorepo incremental typing for that regime. Keep the python-version identical across both jobs — mypy’s inference and the errors it surfaces differ between interpreter targets, so a gate on 3.12 and an advisory run on 3.11 can legitimately disagree.

Edge cases

A handful of situations turn the scoped run from “fast but incomplete” into “quietly wrong,” and each has a different signature. The three below are the ones that most often let a broken tree merge, contrasted against what a full mypy . would have caught.

Edge cases: scoped run versus full run For shallow checkout, package re-exports, and deleted-module importers, the scoped run misses what the full run catches. Edge case Scoped run Full run Shallow checkout (depth 1) diff fails not needed __init__.py re-export change missed caught Deleted module, dangling import missed caught
The scoped column is where regressions slip through; the full run is uniformly complete.
  • Shallow checkout breaks the diff. actions/checkout defaults to fetch-depth: 1, fetching a single commit; the PR base SHA isn’t present in the local history, so git diff "$base"...HEAD dies with fatal: bad object. Set fetch-depth: 0 to fetch the full history (tj-actions/changed-files documents the same requirement). On PRs, checkout also checks out the refs/pull/N/merge ref by default, so confirm the base you diff against is the one you expect.
  • Renames and __init__.py edits. Touching a package __init__.py can change what names it re-exports for many modules, none of which are in the diff — a widened or removed from .core import Widget alters resolution for every from mypackage import Widget. Scoped runs are blind to this; full runs catch the resulting [attr-defined] or [import-not-found]. A pure rename is doubly deceptive: the old path shows as a deletion and the new path as an addition, but the importers still referencing the old name are untouched files the scoped run ignores.
  • Deleted modules. A removed file may leave a dangling import elsewhere — Cannot find implementation or library stub for module named "service.legacy" [import-not-found]. The deletion appears in the diff but the now-broken importer does not, so scoped mypy misses it. Related, a # type: ignore in an unchanged file may become an [unused-ignore] (or the reverse) once the whole program is re-inferred, which only a full run will flag.

Common mistakes

Most failures with this pattern trace back to trusting the scoped run for more than it can deliver. Three recur often enough to name, each with the corrective action.

Three mistakes and their fixes Each row pairs a common changed-files mistake with the corrective practice. Mistake Fix Scoped job is the gate cross-file errors merge Require full main job in branch protection No --diff-filter=d deleted paths -> [misc] Exclude deletions no phantom-path noise Scope pyright likewise same gap, no cache Prefer full + cache or accept the scope gap
Each mistake has a one-line correction; the through-line is: let the full run be authoritative.
  • 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, and the first sign of trouble is a red full-mypy on main after the merge — exactly the broken-trunk situation the gate exists to prevent.
  • Forgetting --diff-filter=d. Passing a deleted path makes mypy emit [misc] “cannot find module” noise unrelated to the actual change, and worse, a red job for the wrong reason trains reviewers to ignore the check. Exclude deletions in the collector so the list only ever contains files that still exist.
  • Scoping pyright the same way. Pyright also does whole-program analysis; scoping it to changed files has the identical reverse-dependency unsoundness, and because pyright keeps no persistent .mypy_cache-style artifact between runs, there is no caching speedup to fall back on. For pyright the realistic choices are a full run every time or accepting the scope gap knowingly — see pyright vs mypy for the trade-offs.

FAQ

Can I make changed-files mypy sound by adding --follow-imports? No. --follow-imports controls whether imported modules are analyzed (and whether their errors are printed), but it only ever affects modules downstream of the files you named. The reverse dependency — callers of what you changed — is never reached unless you pass those callers too, and no follow-imports mode adds them to the graph.

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, because mypy’s incremental mode only re-checks modules whose sources or dependencies changed. Reach for changed-files scoping only when even the cached full run is too slow — and remember pyright gains nothing from this, since it caches nothing between CLI runs.

Back to GitHub Actions Type Checking