GitHub Actions Cache Dependencies: Keys That Actually Hit
Cache npm, pip, Gradle and other dependencies in GitHub Actions with actions/cache keys and restore-keys, plus the 10 GB limit and why caches miss.

To cache dependencies in GitHub Actions, add an actions/cache step before your install step. Point path at the directory your package manager downloads into, and build key from the runner OS plus a hash of your lockfile. On the next run with an unchanged lockfile, the step restores that directory before npm install or pip install runs, so the install reads from disk instead of pulling every package again.
For pip, pipenv and poetry you don't need a separate step at all. actions/setup-python has a cache input that wires up the same mechanism with one line. The rest of this page covers how keys and restore-keys are matched, the 10 GB per-repository limit and 7-day eviction, the reasons a cache silently misses, and a before/after npm workflow you can copy. If you don't have a workflow yet, start with your first workflow that runs tests and come back.
The smallest actions/cache step that works#
steps:
- uses: actions/checkout@v7
- name: Cache npm downloads
id: cache-npm
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm install
Three things happen here. At the start of the job the action looks for a cache matching key, falls back to restore-keys, and unpacks whatever it finds into path. Then your install step runs normally. At the end of the job, if no cache existed under that exact key and the job succeeded, the action uploads the contents of path as a new cache. If the key already existed, nothing is saved.
The inputs, per the actions/cache README:
| Input | Required | What it does |
|---|---|---|
key |
yes | Exact key to look up and to save under. Max 512 characters; longer keys fail the action. |
path |
yes | One or more lines of files, directories or glob patterns. Absolute, or relative to the workspace. |
restore-keys |
no | Ordered list of prefixes to try when key misses. |
enableCrossOsArchive |
no | Lets Windows runners save and restore caches across operating systems. Default false. |
fail-on-cache-miss |
no | Fail the job if nothing is found. Default false. |
lookup-only |
no | Check whether a cache exists without downloading it. Default false. |
The action also exposes a cache-hit output. It's true only when the exact key matched, and false when a restore-key matched or nothing was found. That distinction trips people up, so it gets its own entry in the gotchas below.
How cache keys and restore-keys are matched#
The lookup order, as described in GitHub's dependency caching reference, is:
- An exact match on
keyand cache version, in the branch running the workflow. - A prefix match on
keyin the same branch. - Each entry in
restore-keys, in order, as a prefix match. - The same three steps again, but against the default branch.
When a prefix matches more than one cache, the most recently created one wins. So with a restore key of Linux-npm-, a new feature branch picks up the newest npm cache from the default branch, and npm install only has to fetch whatever changed since then.
"Cache version" is a hash of the path list and the compression tool the runner used. Two caches with the same key but different versions are treated as different caches. That's why an Ubuntu job can't restore a cache saved by a Windows job, and why changing the path list makes every existing cache invisible.
Here's a small model of the key-matching part of that algorithm. It ignores version and branch scope, but it shows how primary keys, prefixes and recency interact.
# Models how actions/cache picks a cache: exact key, then the key as a
# prefix, then each restore-key as a prefix. Ties go to the newest cache.
caches = [
{"key": "Linux-npm-aaa111", "created": 1},
{"key": "Linux-npm-bbb222", "created": 3},
{"key": "Linux-npm-ccc333", "created": 2},
{"key": "Windows-npm-bbb222", "created": 4},
]
def find(key, restore_keys):
exact = [c for c in caches if c["key"] == key]
if exact:
return exact[0], True
for prefix in [key, *restore_keys]:
hits = [c for c in caches if c["key"].startswith(prefix)]
if hits:
return max(hits, key=lambda c: c["created"]), False
return None, False
for key in ["Linux-npm-ccc333", "Linux-npm-ddd444", "macOS-npm-ddd444"]:
hit, primary = find(key, ["Linux-npm-"])
found = hit["key"] if hit else "no cache"
print(f"{key:>18} -> {found:<18} cache-hit={str(primary).lower()}")
Built-in caching in setup-python#
For Python projects, skip the separate step. The setup-python README documents a cache input that supports pip, pipenv and poetry. Caching is off by default, so you have to opt in:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
cache: 'pip'
- run: pip install -r requirements.txt
The action looks for a dependency file in the repository and hashes it into the key: requirements.txt or pyproject.toml for pip, Pipfile.lock for pipenv, poetry.lock for poetry. If your dependency files live in subdirectories or you use several of them, set cache-dependency-path to the files you want hashed.
What gets cached differs by tool. For pip it's the global pip cache directory, so pip install still runs but finds most packages locally. For pipenv it's the virtualenv directory, and for poetry it's one virtualenv per poetry project found.
One warning from the README is easy to miss: if your requirements file uses loose specifiers like chardet>=3.0.4 or no version at all, pip will try to install the latest release every time, and a restored cache won't help much. Pin versions and bump them deliberately if you want the cache to pay off.
Other setup actions ship the same idea with their own input names. Check the README of the one you use before writing a manual actions/cache step for it.
Paths and keys for npm, Gradle, Maven, Go, Composer and Rust#
The pattern is the same for every ecosystem: cache the tool's download directory, hash the lockfile. The actions/cache examples page has a maintained snippet for each tool below plus Bun, Deno, NuGet, Mix, Cabal, Bundler, SBT, Swift and more. Use it for the exact path values, since they vary by OS and tool version.
| Tool | Cache path | File to hash in the key |
|---|---|---|
| npm | ~/.npm (Linux and macOS) |
package-lock.json |
| pip, pipenv, poetry | handled by the setup-python cache input |
requirements.txt, pyproject.toml, Pipfile.lock, poetry.lock |
| Gradle | ~/.gradle/caches and ~/.gradle/wrapper |
your Gradle build files |
| Maven | see the examples page | pom.xml files |
| Go modules | see the examples page | go.sum |
| Composer | see the examples page | composer.lock |
| Rust (Cargo) | see the examples page | Cargo.lock |
The Gradle case shows a multi-line path, taken from GitHub's docs:
- name: Cache Gradle packages
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
restore-keys: |
${{ runner.os }}-gradle-
The detail that matters is that hashFiles flips the entire key the moment one byte of the lockfile changes. This script shows what that looks like:
// hashFiles() changes the key when any byte of the lockfile changes.
const { createHash } = require('crypto');
const lock = '{"name":"app","lockfileVersion":3,"packages":{"node_modules/left-pad":{"version":"1.3.0"}}}';
const bumped = lock.replace('1.3.0', '1.3.1');
const key = (s) => 'Linux-npm-' + createHash('sha256').update(s).digest('hex').slice(0, 8);
console.log('before bump:', key(lock));
console.log('after bump: ', key(bumped));
console.log('same input gives same key:', key(lock) === key(lock));
The real hashFiles output is a full SHA-256 digest. GitHub's docs show it shortened to eight characters for readability, which is what the script does too.
Cache limits, eviction and what it costs#
Numbers from GitHub's dependency caching reference:
| Limit | Value |
|---|---|
| Unused cache lifetime | Removed after 7 days without being accessed |
| Number of caches per repository | No limit |
| Total cache size per repository | 10 GB by default |
| Maximum configurable size, user-owned repos | 10 TB |
| Cache uploads | 200 per minute per repository |
| Cache downloads | 1500 per minute per repository |
When a repository passes its size limit, GitHub still saves the new cache, then evicts caches by last access date, oldest first, until the total is back under the limit. If your workflows produce more than 10 GB of distinct caches, that eviction loop becomes cache thrashing: entries get created and deleted constantly and nothing hits. The fix is either to cache less (drop a workflow, or cache a smaller directory) or to raise the limit in the repository's Actions settings. Raising it costs money if the space is used. Usage beyond 10 GB is billed to your account under the Actions Cache Storage SKU, and the docs give these illustrative monthly costs when the space is fully utilized:
| Cache size | Monthly cost (if fully utilized) |
|---|---|
| 50GB | $2.80 |
| 200GB | $13.30 |
| 1000GB | $69.30 |
Budgets interact with this. If you set an Actions Cache Storage budget and exceed it, the cache goes read-only until billing is resolved or usage drops back under the free 10 GB. A $0 budget with a 20GB configured limit means the cache turns read-only as soon as you cross the free threshold, which is probably not what you wanted.
If you exceed the upload or download rate limits, further cache calls fail until the limit resets, and the wait time comes back in the Retry-After header. These numbers cover the dependency cache only; GitHub's separate Actions limits page covers other quotas.
Why the cache misses#
Work through these in order when a step reports no cache found.
- The lockfile changed. New hash, new key, no exact match. That's by design. Add
restore-keysso a near miss still restores the previous cache and your install only fetches the difference. - The version doesn't match. You changed the
pathlist, moved the job betweenubuntu-latestandwindows-latest, or the compression tool differs. The README suggests the list caches API to inspect each cache's version when debugging this. - You're on a branch that can't see the cache. A branch sees its own caches and the default branch's. It doesn't see caches from sibling feature branches. If nothing on the default branch saves a cache, every new branch starts cold. Make sure a
pushworkflow on the default branch runs the cache step. - The trigger is read-only. Only
push,workflow_dispatch,repository_dispatch,delete,registry_package,page_buildandschedulecan write caches in the default branch's scope. Runs onpull_request_target,issue_comment,workflow_runand similar low-trust triggers can restore but not save. Fork pull requests are read-only too. The save fails with a warning, the job still passes, and the next run misses again. - It was evicted. Nobody touched it for 7 days, or the repository crossed its size limit. Scheduled or rarely-run workflows hit the 7-day rule a lot.
- The job failed. A cache is only saved when the job completes successfully. Fix the failing test, re-run, and the cache appears.
- The key is too long. Past 512 characters the action fails outright rather than missing.
For point 4, the cache-mode workflow key controls what a job's token may do:
cache-mode |
Restore | Save |
|---|---|---|
read |
yes | no |
write |
yes | yes |
write-only |
no | yes |
none |
no | no |
You can set it at the workflow level, the job level, or both, and the job-level value wins. Declaring write on a low-trust trigger overrides the safe default, and GitHub warns that this reopens cache poisoning: an attacker who gets code to run in that workflow can plant a cache that a more privileged workflow later restores. The recommended shape is a push-triggered workflow on the default branch that writes the cache, with low-trust workflows using actions/cache/restore so the intent is explicit and the warning goes away. If you run something like a Claude Code PR review workflow on pull_request_target, that's exactly the setup to use.
Before and after: a full npm workflow#
Before. Every run downloads every package:
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm install
- run: npm test
After. The npm download cache is restored before install, keyed on the lockfile, with a prefix fallback:
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Cache npm downloads
id: cache-npm
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Report cache state
if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
run: echo "No exact cache hit for this lockfile; npm will fetch what is missing"
- run: npm install
- run: npm test
Note that npm install still runs on a hit. The cache holds npm's download store, not node_modules, so npm still has to link packages into place. That's the shape GitHub's own example uses, and it's the safe default: skipping install entirely is only correct when you cache the final install directory and got an exact cache-hit.
Gotchas that cost people an afternoon#
cache-hitis false on a restore-keys hit. If you gatenpm installoncache-hit == 'true', a partial hit restores an older cache and then skips the install, so the new packages never arrive. The README's advice: install on anything but an exact hit.- A fixed key never refreshes. A key like
Linux-npmsaves once and is never overwritten, because a save only happens when the key doesn't already exist. Either put a hash in the key, or add something that changes, like a date from a previous step:
- name: Get date
id: get-date
run: echo "date=$(/bin/date -u "+%Y%m%d")" >> $GITHUB_OUTPUT
shell: bash
- uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- Caches are readable by anyone who can open a pull request. Forks can open PRs against the base branch and read its caches. Don't let tokens or credentials land in a cached directory.
- Restore has to come before use. The cache step must run before any step that needs the files. Putting it after the install step just saves whatever happens to be there and restores nothing useful.
- Self-hosted runners need a recent version.
actions/cache@v5moved to Node.js 24 and requires runner 2.327.1 or newer. Inside a container, a POSIX-complianttarmust be on the path. Self-hosted Windows runners need GNU tar and zstd for cross-OS caching, and the README recommends them anyway for performance. - Stuck downloads time out, slowly. Each cache segment download has a 10-minute timeout, adjustable with the
SEGMENT_DOWNLOAD_TIMEOUT_MINSenvironment variable. If a job sits on the restore step for ten minutes and then carries on, that's what happened.
Quick reference checklist#
pathis the package manager's download directory, not the install directory, unless you specifically want the latter.keyincludes${{ runner.os }}andhashFiles()of the lockfile, and stays under 512 characters.restore-keyshas at least one prefix so new branches and bumped lockfiles still get a warm start.- Python:
cache: 'pip'(orpipenv,poetry) onsetup-python@v7, pluscache-dependency-pathwhen files live in subdirectories. - A
pushworkflow on the default branch saves the cache; low-trust triggers restore only. - Don't skip the install step on
cache-hitunless the exact key matched and you cached the install directory. - Watch for the 7-day expiry and the 10 GB limit; thrashing means caching less or paying for more.
- Never cache files containing secrets.
Quick answers
How do I cache pip dependencies in GitHub Actions?
Add cache: 'pip' to your actions/setup-python@v7 step. It hashes requirements.txt or pyproject.toml into the key and caches pip's global cache directory, so pip install still runs but mostly reads from disk. Use cache-dependency-path if your requirements files sit in subdirectories.
How do I cache Maven or Gradle dependencies?
For Gradle, use actions/cache with a multi-line path of ~/.gradle/caches and ~/.gradle/wrapper and a key that hashes your Gradle build files. For Maven, follow the same pattern with the path from the actions/cache examples page and a key that hashes your pom.xml files.
What is the GitHub Actions cache storage limit?
Each repository gets 10 GB of cache storage by default, and any cache not accessed for 7 days is removed. Past the limit, GitHub evicts the least recently accessed caches first. Repository admins can raise the limit, and usage beyond 10 GB is billed.
Why does my cache miss on a new branch?
A branch can only see its own caches and the default branch's caches, never a sibling branch's. If no push-triggered workflow on the default branch has saved a cache, every new branch starts cold. Add restore-keys with a prefix so the branch picks up the newest default-branch cache.
Does actions/cache work for Go, Rust and Composer dependencies?
Yes. The approach is identical: cache the tool's download directory and hash the lockfile (go.sum, Cargo.lock or composer.lock) into the key. The actions/cache examples page has a maintained snippet for each of those tools with the correct path.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note