Caching mypy and pyright in GitHub Actions

TL;DR

Cache .mypy_cache with actions/cache keyed on a hash of your lockfile plus the Python version, so mypy’s incremental mode reuses fingerprints across runs. Pyright has no on-disk type cache — cache the pip/uv dependency install instead. Always include the lockfile hash in the key, or a dependency bump can leave a stale cache hiding real errors.

mypy’s incremental mode (on by default) writes a per-module cache of fingerprints and inferred types to .mypy_cache. On a clean CI runner that directory starts empty, so the first run re-analyzes the whole project — the slowest possible path. Persisting the cache between runs with actions/cache lets mypy skip unchanged modules, turning a multi-minute check into seconds. Pyright works differently: it keeps no durable on-disk type cache, so the equivalent speedup comes entirely from caching the dependency install. This page walks through both, step by step.

Step 1: cache the mypy incremental directory

Add an actions/cache step before the mypy run, pointing at .mypy_cache. The restore-keys fallback lets a near-miss key still seed a partial cache rather than starting cold.

# .github/workflows/typecheck.yml — actions/cache@v4, mypy 1.x
- name: Cache .mypy_cache
  uses: actions/cache@v4
  with:
    path: .mypy_cache
    key: mypy-${{ matrix.python-version }}-${{ hashFiles('**/uv.lock') }}
    restore-keys: |
      mypy-${{ matrix.python-version }}-

What the analyzer sees: on a cache hit, mypy reads existing *.meta.json and *.data.json files, compares source mtimes and hashes, and re-checks only modules whose fingerprint changed. The [import]/[no-untyped-def] results for untouched modules are replayed from cache.

Step 2: choose a sound cache key

The key controls correctness, not just speed. It must combine two things: the Python version (inferred types differ across interpreters) and a hash of the dependency lockfile (a dependency upgrade can change a function’s inferred signature). hashFiles() over the lockfile gives a key that rotates exactly when dependencies change.

# key fragments — pick the lockfile your project actually uses
key: mypy-${{ matrix.python-version }}-${{ hashFiles('**/uv.lock') }}            # uv
key: mypy-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}       # poetry
key: mypy-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt') }} # pip-tools

If you omit the lockfile hash and key only on the Python version, an upgraded dependency leaves the old .mypy_cache in place. mypy trusts its cached fingerprints for third-party modules and can miss a fresh [attr-defined] or [arg-type] error the new version would surface.

Why incremental mode needs a persisted cache Incremental mode is enabled by default, but its benefit is entirely cross-run reuse. On an ephemeral CI runner with no restored cache, every job re-analyzes from scratch — incremental mode does nothing for you until the cache survives between runs.

Step 3: cache the dependency install

This is the speedup that helps both checkers, and the only one available to pyright. actions/setup-python has a built-in cache: pip that caches the wheel download directory keyed on your requirements files.

# .github/workflows/typecheck.yml — built-in dependency cache, actions/setup-python@v5
- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: pip
    cache-dependency-path: "**/requirements*.txt"

For uv, cache its global cache directory explicitly:

# .github/workflows/typecheck.yml — uv cache, actions/cache@v4
- name: Cache uv
  uses: actions/cache@v4
  with:
    path: ~/.cache/uv
    key: uv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}

Step 4: pyright — cache deps, not types

Pyright re-analyzes the project on every invocation; it has no .mypy_cache analogue to persist. Attempting to cache a “pyright cache” directory caches nothing useful. The practical lever is making the install fast (Step 3) and pinning the pyright version so its bundled stubs don’t shift.

# .github/workflows/typecheck.yml — pyright run after a cached install, pyright 1.1.x
- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: pip
- run: pip install -e ".[dev]"   # restored from cache when the lockfile is unchanged
- run: pyright                   # full re-analysis every run; no on-disk type cache
Runtime vs static analysis A restored cache speeds the static analysis pass only — it never changes which errors are reported. If a cached run and a cold run disagree, the cache is stale (the key failed to rotate), not "faster but laxer." A correct key guarantees identical diagnostics whether hit or cold.

Edge cases

  • Cache size limits. GitHub evicts caches past the repository’s 10 GB total on a least-recently-used basis. A bloated .mypy_cache from a huge monorepo can self-evict between runs; scope mypy to the packages you actually gate, or accept periodic cold runs.
  • restore-keys partial hits. A prefix match restores an older .mypy_cache. That is safe — mypy re-validates fingerprints — but it can be slower than a cold run if most modules changed. The prefix fallback is a net win on typical PRs where few files move.
  • Matrix legs sharing a key. Each Python version must have its own key. Two matrix legs writing to mypy-${{ hashFiles(...) }} without the version segment will clobber each other’s cache and replay wrong-interpreter fingerprints.

Common mistakes

  • Key without the lockfile hash. key: mypy-3.12 never rotates. After a dependency upgrade the stale cache can suppress a genuine [import] or [attr-defined] error. Always fold hashFiles('**/lockfile') into the key.
  • Caching .mypy_cache but disabling incremental. Passing --no-incremental makes the cached directory dead weight — mypy ignores it and re-analyzes everything. Leave incremental mode on (the default) when you cache.
  • Expecting a pyright type cache. There isn’t one. Trying to cache pyright’s analysis results yields no speedup and can mislead reviewers into thinking the gate is cached when only the install is.

FAQ

Is it safe to share one cache across the whole matrix? No — segment the key by matrix.python-version. Inferred types differ per interpreter, so a shared cache replays results from the wrong target and can mask version-specific errors.

How do I force a clean type-check run? Bump a static prefix in the key (e.g. mypy-v2-...) to invalidate every existing cache, or run mypy with --no-incremental in a one-off job to confirm the cache isn’t hiding anything.

Back to GitHub Actions Type Checking