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.
The cache is two files per source module inside .mypy_cache/<python-version>/. The *.meta.json holds the metadata mypy checks first — the source path, its size, mtime, and a hash of the file’s content plus the hashes of every dependency’s cached data. The *.data.json holds the serialized inferred types (the “fine-grained” symbol table) that mypy replays on a hit. When mypy starts, it stats each source file: if the mtime matches the recorded value it trusts the cache immediately; if the mtime differs but the content hash still matches, it rewrites the metadata but skips the expensive re-analysis. Only a genuine content change forces a fresh type-inference pass for that module — and, transitively, for any module that imported a changed public signature.
That transitive invalidation is why a one-line edit to a widely-imported module can still cost seconds: mypy recomputes the dependents whose view of a symbol changed. It is also why the cache is correct rather than merely fast — a stale .mypy_cache from a code change can never suppress an error, because the source hash no longer matches and mypy re-analyzes. The danger is confined to dependency changes, which the cache key (Step 2) must capture, since third-party stub contents are not hashed into *.meta.json.
You can relocate the directory with --cache-dir (useful when a monorepo runs several mypy invocations that should not share one cache), and mypy 0.780+ offers --sqlite-cache to store the same fingerprints in a single SQLite file instead of thousands of small JSON files. The SQLite form restores faster on filesystems that are slow to unpack many tiny files, which is common on CI runners; the trade-off is that actions/cache then tars a single large file rather than many small ones. Passing --no-incremental disables the whole mechanism and makes any cached directory dead weight — mypy will re-analyze from scratch and never read .mypy_cache. Keep incremental mode on whenever you cache, and use the GitHub Actions type-checking workflow as the surrounding job scaffold.
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.
hashFiles() computes a SHA-256 over the contents of every file matching the glob, not their names or mtimes, so the segment changes only when a locked version actually moves. Order the glob to match your tooling: uv.lock and poetry.lock pin transitive dependencies exactly, which is what you want, whereas an unpinned requirements.txt with loose >= constraints hashes the same even after a floating dependency resolves to a new release — in that case hash the fully-pinned compiled output (requirements.lock from pip-compile) instead. If the glob matches nothing, hashFiles() returns the empty string and your key silently collapses to mypy-3.12-, which never rotates; guard against that by confirming the lockfile path is committed.
The key is the exact string mypy’s cache is saved under at the end of a successful run, and the first entry actions/cache tries to restore. restore-keys is an ordered list of prefixes tried only when the exact key misses: the action walks them top to bottom and restores the most recent cache whose key starts with the prefix. That is why restore-keys: | mypy-${{ matrix.python-version }}- is worth keeping — after a lockfile bump the exact key misses, but the previous run’s cache (same Python version, older hash) is still a useful warm start, and mypy re-validates every fingerprint against the new dependency set anyway. A restored-by-prefix cache is never saved back under the old key; a fresh save happens under the new exact key, so the cache converges after one run. For the interaction with matrix legs, keep each interpreter on its own key segment, as detailed in matrix-testing mypy across Python versions.
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') }}
The two caching layers are independent and stack. cache: pip on setup-python persists pip’s HTTP wheel cache (~/.cache/pip on Linux) and derives its own key from the files named in cache-dependency-path; you do not write a key for it. uv keeps a richer content-addressed store under ~/.cache/uv — unpacked wheel archives it can hard-link into a virtualenv — so a warm uv cache skips both the download and much of the unpack cost, which is why uv installs are near-instant on a hit. Whichever tool you use, the install cache and the .mypy_cache are separate actions/cache entries with separate keys: the dependency cache rotates on the lockfile, and the type cache rotates on the lockfile and the Python version. Do not fold them into one entry — their restore paths and correct keys differ.
Ordering matters inside the job. Restore the dependency cache (via setup-python’s built-in step or an explicit actions/cache) before pip install, so the install reads from the warm cache; restore .mypy_cache before the mypy step. Because actions/cache saves in a post-job step only when the run succeeds, a failed job does not poison the cache with a partial write — but it also means the very first green run on a branch is what seeds the cache for everyone after. This install speedup is the entire story for pyright, which the next step makes explicit.
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
The architectural reason is that pyright is a from-scratch checker built for editor responsiveness: it holds its binding and type-evaluation results in memory for the lifetime of the process (which is how the language server stays fast under --watch), but it serializes none of that to disk between separate CLI invocations. Each pyright command re-parses, re-binds, and re-infers the whole program. There is no flag that changes this; --outputjson only alters the report format, not what work is done. Because the analysis is deterministic given the same sources, stubs, and interpreter, the useful cache is the inputs — the installed dependencies and their bundled type stubs — not the outputs.
That makes version pinning part of the caching story. Pyright ships its own copy of typeshed inside the package, so an unpinned pip install pyright can silently pull a newer bundle whose stubs tighten a signature, producing a reportArgumentType or reportAttributeAccessIssue that did not exist on yesterday’s release even though your code is unchanged. Pin pyright==1.1.x in the lockfile that keys your install cache so the stub set is stable and reproducible from the cache. If you run pyright through the GitHub Actions type-checking workflow alongside mypy, keep them in separate jobs: mypy’s job carries the .mypy_cache step and pyright’s carries only the install cache, so neither job pays for a cache the other needs.
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.
Each edge case has a concrete signature. The 10 GB limit is repository-wide across all caches, not per entry, so a chatty monorepo that caches .mypy_cache for four matrix legs plus a uv store can churn through the budget and evict its own oldest entries mid-week — you see it as intermittent cold runs on Mondays. GitHub also scopes caches by branch: a cache created on a feature branch is readable by that branch and by child branches, but the default branch’s caches are the shared baseline every PR restores from, so seed the cache by landing a green run on main rather than expecting each PR to bootstrap its own.
The restore-keys prefix walk has a subtle failure mode worth naming: if most modules changed (a large refactor, a formatter sweep, or a from __future__ import annotations roll-out across the tree), the restored older cache invalidates almost every fingerprint and mypy re-analyzes nearly everything anyway — the restore cost is then pure overhead. That is rare on incremental PRs and not worth optimizing away; it is only a reason not to be surprised when one sweeping PR runs as slowly as a cold build. And the matrix-leg collision is the most dangerous because it is silent: two legs sharing mypy-${{ hashFiles('**/uv.lock') }} (no version segment) each save over the other’s cache, so a 3.9 leg can restore a .mypy_cache written by the 3.12 leg and replay 3.12 stub fingerprints against 3.9 target semantics, potentially masking an [attr-defined] the older stub would flag.
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.
The first mistake is the one that silently weakens the gate rather than merely slowing it, so it deserves a habit: never write a key without a hashFiles() segment over a pinned lockfile. If you must invalidate every cache at once — after upgrading mypy itself, whose serialized cache format is versioned and occasionally incompatible — bump a static prefix (mypy-v2-...) rather than deleting caches by hand; the old entries age out under LRU. The second mistake usually sneaks in when someone adds --no-incremental to “get a clean run” during debugging and forgets to remove it: the cache step still runs, still saves, and still costs upload time, while mypy quietly re-analyzes from scratch every build. The third is a review-time trap — a job that caches a pyright/ directory looks optimized in the YAML but does nothing, so reviewers should treat any pyright cache step as a smell and check that the real lever, the install cache from Step 3, is present instead. All three failure modes are cheap to catch in review and expensive to catch in production, where a suppressed [arg-type] reaches a user.
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.