Skip to content
compiler.dev

Caching in GitHub Actions: Keys, Limits, Examples

A good cache turns a three-minute dependency install into ten seconds. A bad one saves nothing, or worse, restores stale files that break your build. This guide explains how the GitHub Actions cache chooses what to restore, how to write keys, and what limits to plan for.

The two ways to cache

Built-in caching in setup actions. For common ecosystems, one line is enough:

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: pnpm          # npm, yarn or pnpm
- uses: actions/setup-python@v5
  with:
    python-version: "3.13"
    cache: pip
- uses: actions/setup-go@v5
  with:
    go-version-file: go.mod

These cache the package manager's download directory, keyed on the lockfile. setup-go caches by default. Use these when they fit; there is nothing to maintain.

actions/cache for everything else. You choose the path and the key:

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      gradle-${{ runner.os }}-

How key matching works

On a cache lookup, GitHub first looks for an exact match on key. If there is none, it tries each line of restore-keys in order and picks the most recently created cache whose key starts with that prefix. On an exact hit the action sets cache-hit to true and skips the save step at the end of the job. On a partial hit via restore-keys, the job runs, then saves a new cache under the full key.

That behaviour is the point of restore-keys. When your lockfile changes by one dependency, the exact key misses, but the prefix restores yesterday's cache, so the install only fetches the difference.

A good key has three parts: the OS and architecture, the tool version, and a hash of the inputs.

key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }}

The cache key builder generates keys and restore-keys for common stacks.

Scope and limits

These come from GitHub's docs on dependency caching and usage limits:

  • A workflow can restore caches created on the current branch and on the base branch (and the default branch). A feature branch can read main's cache; main cannot read a feature branch's cache. Sibling branches cannot read each other's.
  • For pull requests, caches are scoped to the merge ref, so a PR's cache is not reusable by another PR.
  • Caches not accessed for 7 days are removed.
  • Default total cache storage is 10 GB per repository. When you go over, GitHub evicts the least recently used caches. Owners can raise the limit, and storage above the included amount is billed.
  • Cache entries are immutable. You cannot update a key; you create a new one.

The practical consequence: make sure main populates the cache. Run the same cache step on pushes to main, so every PR starts from a warm cache.

What to cache, and what not to

Cache thisKey onSkip this
npm, pnpm, yarn download cachepackage-lock.json, pnpm-lock.yaml, yarn.locknode_modules itself
pip, Poetry, uv download cacherequirements*.txt, poetry.lock, uv.lockWhole virtualenvs across Python versions
Gradle, Mavenbuild files, wrapper propertiesBuild outputs with absolute paths
CargoCargo.locktarget/ of a huge workspace, often bigger than the time it saves
Compiler caches (ccache, sccache)OS, compiler versionNothing; these are worth it

Large caches have a cost. A 2 GB cache that takes 40 seconds to download and extract may not beat a 30 second install. Measure restore time in the step log.

Common reasons for cache misses

  1. The key includes something that changes every run, such as github.sha or a timestamp. Use it only as the last part of a key paired with restore-keys for rolling caches.
  2. hashFiles matches nothing. It returns an empty string, so every run uses the same key and never updates. Check the glob.
  3. Different OS or architecture. Include runner.os and runner.arch.
  4. Path differs between save and restore. Paths are part of the cache identity.
  5. Branch scope. A new branch sees only its own and the default branch's caches.
  6. Eviction. Check the repository's Actions caches list. With many branches, 10 GB fills quickly.

List and delete caches from the CLI:

gh cache list --limit 50
gh cache delete <cache-id>

Rolling caches for build output

For incremental compilers, you want each run to start from the last run's output and then save the new one. Use a unique key with a prefix fallback:

- uses: actions/cache@v4
  with:
    path: .turbo
    key: turbo-${{ runner.os }}-${{ github.sha }}
    restore-keys: |
      turbo-${{ runner.os }}-

Every run misses the exact key, restores the latest prefix match, and saves a new entry. Watch the 10 GB limit with this pattern; the oldest entries are evicted automatically.

Separate restore and save

Use actions/cache/restore and actions/cache/save to save only when the job succeeds, or only from main:

- uses: actions/cache/restore@v4
  id: cache
  with:
    path: ~/.m2
    key: m2-${{ hashFiles('**/pom.xml') }}
- run: mvn -B verify
- uses: actions/cache/save@v4
  if: github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true'
  with:
    path: ~/.m2
    key: m2-${{ hashFiles('**/pom.xml') }}

That keeps PR branches from filling your quota.

FAQ

How big can a GitHub Actions cache be?

The total for a repository is 10 GB by default, as listed in GitHub's usage limits.

Can I share a cache between workflows?

Yes, if the key and path match and the branch scope allows it. Caches belong to the repository, not to one workflow.

Should I cache Docker layers here too?

Docker layers have their own backends. See Docker layer caching in CI.

Is a cache hit guaranteed to be safe?

An exact key hit restores exactly what was saved under that key. Safety depends on the key covering every input that affects the files.

Where this fits

Caching is one of several fixes in how to speed up GitHub Actions. If caching helps but a job is still slow, check whether the job is CPU-bound; compiler.dev's comparison mode runs your jobs on a faster machine so you can see the difference on your own workflow.

Made by compiler.dev. Free tools · Pricing