CoreMesh

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.

GitHub Actions Cache Dependencies: Keys That Actually Hit

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#

yaml
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:

  1. An exact match on key and cache version, in the branch running the workflow.
  2. A prefix match on key in the same branch.
  3. Each entry in restore-keys, in order, as a prefix match.
  4. 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.

python
# 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:

yaml
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:

yaml
- 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:

javascript
// 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.

  1. The lockfile changed. New hash, new key, no exact match. That's by design. Add restore-keys so a near miss still restores the previous cache and your install only fetches the difference.
  2. The version doesn't match. You changed the path list, moved the job between ubuntu-latest and windows-latest, or the compression tool differs. The README suggests the list caches API to inspect each cache's version when debugging this.
  3. 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 push workflow on the default branch runs the cache step.
  4. The trigger is read-only. Only push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule can write caches in the default branch's scope. Runs on pull_request_target, issue_comment, workflow_run and 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.
  5. 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.
  6. The job failed. A cache is only saved when the job completes successfully. Fix the failing test, re-run, and the cache appears.
  7. 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:

yaml
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:

yaml
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-hit is false on a restore-keys hit. If you gate npm install on cache-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-npm saves 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:
yaml
- 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@v5 moved to Node.js 24 and requires runner 2.327.1 or newer. Inside a container, a POSIX-compliant tar must 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_MINS environment variable. If a job sits on the restore step for ten minutes and then carries on, that's what happened.

Quick reference checklist#

  • path is the package manager's download directory, not the install directory, unless you specifically want the latter.
  • key includes ${{ runner.os }} and hashFiles() of the lockfile, and stays under 512 characters.
  • restore-keys has at least one prefix so new branches and bumped lockfiles still get a warm start.
  • Python: cache: 'pip' (or pipenv, poetry) on setup-python@v7, plus cache-dependency-path when files live in subdirectories.
  • A push workflow on the default branch saves the cache; low-trust triggers restore only.
  • Don't skip the install step on cache-hit unless 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.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

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

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS