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.

mypy incremental cache read path A restored cache is read, each module's fingerprint is compared, and unchanged modules replay results while changed modules are re-analyzed. cache restore .mypy_cache read *.meta.json + *.data.json compare mtime + source hash hit → replay skip re-analysis miss → recheck re-infer types
On a hit mypy compares each module's fingerprint and replays cached results; only changed modules are re-analyzed.

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.

Cache key composition and rotation A cache key is built from a static prefix, the Python version, and a lockfile hash, and only the hash segment changes when dependencies change. key = mypy static prefix + 3.12 matrix.python-version + hashFiles('**/uv.lock') rotates on dependency change stable stable per leg changes → new cache a dependency bump forces a fresh, correct cache rather than reusing a stale one
The key concatenates a stable prefix, the per-leg Python version, and a lockfile hash; only the hash rotates, invalidating the cache exactly when dependencies move.

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') }}
Cold install versus restored dependency cache Without a cache the job downloads wheels from PyPI over the network; with a restored cache the same wheels install from local disk. cold (no cache) PyPI network download every wheel install slow: bound by network + PyPI warm (cache hit) ~/.cache/uv (disk) install no network
A cold job downloads every wheel from PyPI; a restored pip or uv cache installs the same wheels from local disk with no network round-trip.

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
mypy versus pyright caching capabilities A matrix contrasts mypy's persistent incremental type cache with pyright's absence of one, leaving only the dependency install to cache for pyright. capability mypy pyright on-disk type cache yes — .mypy_cache none cross-run reuse per-module replay full re-analysis what to cache cache + install install only
mypy persists a per-module type cache; pyright keeps none, so the only durable speedup for pyright is caching the dependency install.

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_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.
Cache health decision tree Three checks — total size under ten gigabytes, a prefix restore fallback present, and a per-Python-version key segment — lead to a healthy reusable cache. total < 10 GB? repo cache budget yes no restore-keys set? prefix fallback per-leg key? version segment healthy reused scope mypy to gated packages or accept periodic cold runs
Pass three checks — under the 10 GB budget, a prefix fallback, and a per-version key — and the cache is reused; fail the size check and scope mypy down.

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.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.
Three caching mistakes and their fixes A ladder pairs each of three caching mistakes with the symptom it causes and the corrective action. mistake symptom fix key lacks lockfile hash key: mypy-3.12 stale cache hides [import] / [attr-defined] add hashFiles(lockfile) cache + --no-incremental dead-weight dir re-analyzes everything keep incremental on expect pyright cache no such thing no speedup, false confidence cache the install
Each mistake maps to a concrete symptom and a one-line fix: hash the lockfile, keep incremental on, and cache pyright's install rather than a nonexistent type cache.

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.

Back to GitHub Actions Type Checking