Caching Dependencies in GitHub Actions
How actions/cache works, how to write a key that actually hits, and why a cache can make a build slower. With the built-in caching in the setup actions.
Every job starts on an empty machine, so every job downloads its dependencies again. Caching stores a directory after a successful job and restores it on the next run, keyed on a string you choose, and almost all the difficulty is in choosing that string.
Verified against actions/cache@v4 as of September 2026.
Use the Built-In Caching First
Before reaching for actions/cache directly, check whether your setup action already does it. Several do, and their configuration is one line:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # also 'yarn' or 'pnpm'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
These handle the key, the path and the restore logic correctly for their ecosystem. For a straightforward Node, Python or Java project this is the whole answer, and writing a manual cache step instead is a common way to make things slower.
The Manual Form
When you need to cache something the setup actions do not cover, such as a build output directory or a Go module cache:
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
Three things are happening.
key is the exact identity of this cache. If a cache with this key exists, it is restored and the step reports a hit.
restore-keys is an ordered list of prefixes tried when the exact key misses. The newest cache whose key starts with the prefix is restored. This is what turns a total miss into a partial hit: you get last week's module cache and download only what changed, instead of downloading everything.
hashFiles('**/go.sum') produces a hash of the matched files. Including it means the key changes exactly when the dependencies change, which is the property you want.
Writing a Key That Works
The key needs to change when the cached content should change, and not otherwise. Two failure modes come from getting this wrong.
A key that never changes gives you a cache that is restored forever and never updated, because actions/cache only saves when the key missed. A stale dependency cache then silently pins you to old packages. This is the more dangerous of the two because the build keeps passing.
A key that changes on every run, for example one including github.sha, means every run misses, saves a fresh cache and gains nothing but upload time. You will see a cache step taking 40 seconds with a 100% miss rate.
Include the OS. A cache built on ubuntu-latest is not valid on windows-latest, and runner.os in the key prevents restoring a Linux cache onto a Windows runner. Where a job uses a matrix of language versions, include that too, or the Node 18 cache will be restored into the Node 22 job.
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
Do Not Cache node_modules
This is the most common caching mistake in the Node ecosystem. Caching node_modules directly appears to work and then produces builds that cannot be reproduced.
The reason is that node_modules contains compiled native modules built against the exact Node version and platform of the job that created it, and npm's install step performs work beyond unpacking files: linking binaries, running lifecycle scripts, pruning. Restoring the directory skips all of that.
Cache the npm download cache instead, which is what cache: 'npm' on setup-node does, and let npm ci run every time. The install is fast because nothing needs downloading, and the result is correct.
Cache Behaviour Worth Knowing
Caches are saved only on success. If the job fails after the cache step, nothing is stored. A job that always fails therefore never populates its cache, which can look like the cache being broken.
Saving happens in a post-step. actions/cache registers a post-job hook, so the upload appears at the very end of the job log, not where the step sits in your file.
Branch scoping is asymmetric. A cache created on a branch is visible to that branch and to child branches. A cache created on the default branch is visible to all branches. A cache created on a feature branch is not visible to other feature branches. This is a security boundary, and it means the first run on a new branch usually only hits caches from main.
There is a 10 GB limit per repository, with least-recently-used eviction. A workflow caching several large directories across a wide matrix can evict its own caches between runs, producing a confusing pattern of intermittent misses. The Actions caches page in repository settings shows what is stored and its size.
Individual entries expire after 7 days without access.
Restore Without Saving
Sometimes you want to read a cache but never write it, for example in a job that only needs to look something up:
- uses: actions/cache/restore@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
The matching actions/cache/save@v4 does the reverse. Splitting them is also how you save a cache even when a later step fails, by putting the save step under if: always().
Measuring Whether It Helped
Add up the numbers rather than assuming. The cache step logs both the restore time and whether it hit, and the install step shows its own duration. A cache is worth keeping when restore time plus install-with-cache is meaningfully less than install-without-cache.
For a small project with few dependencies, that difference can be negative: restoring a 200 MB cache over the network takes longer than downloading the handful of packages you actually need. Caching is a tool for large dependency trees, not a default.
If CI is running out of disk rather than time, the no space left on runner page covers the causes, and large restored caches are one of them.
Frequently Asked Questions
Why does my cache always miss?
Print the key. The usual cause is hashFiles matching nothing, which yields an empty hash and a key that looks valid but never corresponds to a saved cache. hashFiles('**/package-lock.json') returns an empty string if no lockfile is committed. The second most common cause is a key containing github.sha or a timestamp.
Can I share a cache between jobs in the same workflow?
Yes. Caches are scoped to the repository and branch, not to a job, so a cache saved by job A is restorable by job B in the same run provided A finished first. Use needs: to guarantee the order, otherwise the parallel job may start before the cache exists.
Does caching work on self-hosted runners?
Yes, and the trade-off changes. A self-hosted runner often keeps its working directory between jobs, so dependencies may already be present and a network cache restore is pure overhead. Measure before adding it.
How do I clear a cache?
Delete it from the Actions caches page in repository settings, or via the REST API. You cannot overwrite a cache: a given key is immutable once saved, which is why changing the cached content requires changing the key.
Should I cache Docker layers this way?
Not with actions/cache. Docker Buildx has purpose-built cache backends that are faster and handle layer semantics correctly, covered in the Docker build and push lesson.