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 autoupdateand 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.
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
revbump 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.
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.
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.
Related Jump to heading
- The pre-commit Framework for Polyglot Repositories β the parent guide, including the CI job this recipe optimises.
- Migrating from Husky to the pre-commit Framework β how the configuration this cache keys on comes to exist.
- Optimizing CI Triggers for Path-Specific Changes β the other half of pipeline latency: not running the job at all when nothing relevant changed.