Caching mypy and pyright in GitHub Actions
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.
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
Edge cases
- Cache size limits. GitHub evicts caches past the repository’s 10 GB total on a least-recently-used basis. A bloated
.mypy_cachefrom a huge monorepo can self-evict between runs; scope mypy to the packages you actually gate, or accept periodic cold runs. restore-keyspartial 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.12never rotates. After a dependency upgrade the stale cache can suppress a genuine[import]or[attr-defined]error. Always foldhashFiles('**/lockfile')into the key. - Caching
.mypy_cachebut disabling incremental. Passing--no-incrementalmakes 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.