Caching pre-commit environments in CI Jump to heading

The first time a CI runner executes the pre-commit framework, it builds an isolated environment for every hook repository in the configuration: a virtualenv here, a module download there, a node_modules tree for the formatter. On a polyglot repository that is comfortably three to five minutes, repeated on every pull request, for work whose output is byte-for-byte identical every time. Caching that directory correctly turns the job into one of the fastest checks in the pipeline. Caching it incorrectly produces something worse than a slow job: a fast job that quietly runs the wrong linter version.

When to use this approach Jump to heading

  • Your lint job takes minutes and the log shows most of it before the first hook runs.
  • The repository has hooks in more than one language, so several environments are built per run.
  • You gate merges on the lint job, so its latency is on the critical path of every review.
  • You update pins with pre-commit autoupdate and need the cache to notice, which a naive fixed key will not.
  • You are running the same configuration locally and in CI, as set out in the parent guide β€” the cache should never change which checks run, only how long provisioning takes.

Step 1 β€” Measure where the cold time actually goes Jump to heading

Before optimising, confirm the assumption. The framework reports each phase, and the split is usually lopsided.

# Locally, reproduce a cold run to see the breakdown
rm -rf ~/.cache/pre-commit
time pre-commit run --all-files
# Typical: ~3-4 min building environments, ~10-20 s actually linting

What changed: nothing β€” you now know whether provisioning or linting dominates. If linting dominates, caching will not help and the answer is scoping, not caching.

Where the time goes, cold versus cachedIn a cold run, building the Python, Go and Node environments accounts for roughly 200 of 220 seconds while linting takes about 20. In a cached run, restoring the archive takes about 6 seconds and linting the same 20, so total time falls from nearly four minutes to under half a minute.coldbuilding environments β€” 200 slint220 scachedrestore 6 slint26 s0 s120 s240 sthe linting itself is unchanged β€” caching removes provisioning, not work

Step 2 β€” Cache the environment directory with a content-derived key Jump to heading

One path, one key, derived from the file that determines the contents.

# .github/workflows/lint.yml
name: lint
on: [pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - name: Cache pre-commit environments
        uses: actions/cache@v4
        with:
          # Exactly one path: the framework's own store.
          path: ~/.cache/pre-commit
          # The config file determines the contents, so hash the config file.
          # A pin bump changes the hash and rebuilds; nothing else does.
          key: pre-commit-${{ runner.os }}-py3.12-${{ hashFiles('.pre-commit-config.yaml') }}
          # No restore-keys: a partial match would be an environment built
          # from a DIFFERENT configuration, i.e. the wrong linter versions.

      - run: pip install pre-commit
      - run: pre-commit run --all-files --show-diff-on-failure

What changed: the environments survive between runs, and the cache invalidates precisely when a pinned revision changes.

# Verify the key varies with the config and nothing else:
sha256sum .pre-commit-config.yaml
# Edit a rev, re-hash β€” the digest must change

The omission of restore-keys is deliberate and is the single most important decision on this page. Fallback keys are excellent for dependency caches, where a near-miss still saves work and the build corrects itself. They are wrong for a correctness gate: a near-miss here means running linters from a configuration that is not the one under review, and the job goes green having enforced nothing you agreed to.

SAFETY WARNING β€” a lint job that is both cached and green is trusted implicitly by reviewers. If the cache key does not derive from the configuration, that trust is misplaced: a rev bump can land while CI keeps running the previous linter for weeks. Whenever you change the key strategy, verify by bumping a pin in a throwaway pull request and confirming the job rebuilds.

Step 3 β€” Separate installing environments from running hooks Jump to heading

Splitting the two makes the log readable and stops a slow provisioning step from being blamed on a lint failure.

      - name: Install hook environments
        run: pre-commit install-hooks       # cache miss: builds. Cache hit: instant.

      - name: Run hooks
        run: pre-commit run --all-files --show-diff-on-failure

What changed: the job now has two clearly labelled steps, and the timing of each is visible in the run summary β€” which is how you notice a cache that has quietly stopped working.

What the cache key decidesA run computes the hash of the configuration file. An exact match restores the environments and runs the pinned hooks. No match rebuilds them and stores a new entry. A fallback key would restore environments built from a different configuration, running unpinned versions while the job reports success.hash the confighash of the config fileexact matchrestore in secondspinned versions runno matchrebuild, then storefallback keyenv from another configfast and correctthe intended stateslow and correcthappens after a pin bumpfast and wronggreen, enforcing nothing

Step 4 β€” Confirm the cache is hit and never stale Jump to heading

Three checks, run once, prevent a year of silent drift.

# 1. Second run on an unchanged branch must report a cache hit
#    Look for: "Cache restored from key: pre-commit-Linux-py3.12-<digest>"

# 2. Bump a pin and confirm the job rebuilds
pre-commit autoupdate --repo https://github.com/rbubley/mirrors-prettier
git commit -am "chore: bump prettier hook" && git push
#    Look for: "Cache not found for input keys" followed by an install step

# 3. Prove the pinned version is what actually ran
pre-commit run prettier --all-files --verbose | head -5
#    The version in the output must match the rev in the config

Check 3 is the one that matters most and is almost never done. A cached job reports success; only the verbose output tells you which binary produced that success.

Cache behaviour across a series of runsFive consecutive runs. The first builds and stores. The next two hit the cache. A pin bump changes the configuration hash, so the fourth run rebuilds and stores a new entry. The fifth hits the new cache. Rebuilds happen exactly when a pin changes.run 1build + store220 srun 2hit26 srun 3hit26 srev bumped β€” hash changesrun 4rebuild + store220 srun 5hit26 s…

The pattern in that timeline is the goal: rebuilds are rare, predictable, and always explained by a change someone reviewed. If you see rebuilds on runs where nothing changed, the key includes something unstable β€” a timestamp, a run number, or a lock file that is regenerated on every install.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why key the cache on the config file instead of a fixed string? Jump to heading

Because the configuration file is the exact input that determines the contents of the environment directory. Hash it and the cache invalidates precisely when a pinned revision changes and at no other time. A fixed key never invalidates, so a pin bump silently keeps running the old linter β€” the worst possible outcome, since the job stays green while enforcing a version nobody reviewed.

Should I cache the whole home directory instead? Jump to heading

No. Cache only the framework’s environment directory. A broad home-directory cache sweeps up package manager state, credential helpers and temporary files, which makes the archive large, slow to restore, and occasionally a way to store something you did not intend to. One targeted path is both faster and easier to reason about.

Can the cache make a job pass when it should fail? Jump to heading

Only if the key is wrong. With a content-derived key, a restored cache always corresponds to the configuration being run, so the hooks executed are exactly the pinned ones. The failure mode to avoid is a restore-keys fallback that silently accepts an environment built from a different configuration β€” useful for warm-starting a build cache, actively harmful for a correctness gate.