# GitHub Actions cache: keys, scopes and the limits that decide a hit

> How the GitHub Actions cache matches keys and restore-keys, what the 10 GB cap and the 7-day eviction really do, and when to use an artifact instead.

Source: https://latchkey.dev/learn/speed/github-actions-caching-explained  
Updated: 2026-09-20

A GitHub Actions cache is a keyed, immutable archive scoped to one repository, capped at 10 GB by default and removed after 7 days without a read. Almost everything people call a caching bug is one of those four words arriving without a log line to announce it.

Caching in Actions is not a shared filesystem and not a mount. Each entry is an archive stored against an exact string, uploaded when a job finishes and restored when a later job asks for the same string. Nothing is shared implicitly, and one character of difference is a complete miss.

That design is why caching is the largest speed win most pipelines have available and also the quietest way to lose minutes. A miss is not an error. The step prints a line, exits zero, and hands you the install you were trying to avoid.

## What the action does with your key

The step declares one `key` and, optionally, a list of `restore-keys`. The action asks for the exact key first. On a match it restores that archive, sets `cache-hit` to `true`, and the post-job save step does nothing at all, because the entry it would write already exists.

On a miss it walks the `restore-keys` in the order you wrote them, treating each as a prefix and taking the most recent entry that starts with it. That is a partial restore: the files arrive, `cache-hit` stays `false`, and the save step at the end of the job writes a fresh entry under your exact key. If nothing matches either, the action prints `Cache not found for input keys:` followed by every key it tried, and the job continues.

```.github/workflows/ci.yml
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

# then always reconcile, because a partial restore is not the lockfile
- run: npm ci
```

> The `hashFiles` call is the part that makes the key change when your dependencies change. A key built from a branch name or a static string produces a cache that never updates, which is worse than none: you install on top of stale content forever.

## The three limits that decide whether anything is there to match

None of these three announce themselves. The entry is simply not there the next time you ask, and the only visible consequence is the install you thought you had eliminated.

| Limit | What GitHub does | What it looks like |
| --- | --- | --- |
| Total size | 10 GB per repository by default, raisable by enterprise, organization or repository admins | Least recently used entries are evicted to make room. One large layer cache can clear out every dependency cache you had |
| Age | Entries not accessed in over 7 days are removed | A repository that is quiet at weekends is cold again on Monday, with no change to the workflow |
| Scope | A run restores caches from its own branch and the default branch; a pull request also reads its base branch | A feature branch cannot read a sibling branch cache, so every new branch starts cold unless the default branch warms it |

> Quoted from docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, read on 2026-09-20. Cache storage is capped rather than billed; artifact storage is billed against your plan.

## Entries are immutable, and that changes how you design keys

GitHub is explicit about it: "You cannot change the contents of an existing cache. Instead, you can create a new cache with a new key." A second save under a key that already exists does not replace anything. If the content your job produces has changed but the key has not, the new content is simply never stored, and the next run restores the old content forever.

The design rule that falls out of this is short. Every input that changes what ends up in the cached directory belongs in the key. When you need to throw an entry away, you do not delete it, you roll a version prefix and let the old one age out.

```.github/workflows/ci.yml
# every input that changes the contents is in the key, and v2 is the
# handle you turn when you need a clean entry
key: v2-build-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}
restore-keys: |
  v2-build-${{ runner.os }}-
```

## Cache or artifact

The two look similar and behave nothing alike, and using one for the other job is expensive in opposite directions. A cache is an optimization: keyed, evictable, free, and safe to lose. An artifact is an output: named, retained for a set number of days, billed against your plan storage, and safe to depend on.

|  | Cache | Artifact |
| --- | --- | --- |
| Addressed by | A key you compute | A name you choose |
| Survives | Until eviction: 10 GB cap or 7 days unread | The retention period you configure |
| Costs | Nothing, but capped | Billed against plan storage |
| Scoped to | Branch, with default-branch fallback | The workflow run |
| Use it for | Dependencies and build caches you can rebuild | Binaries, reports and anything a later job or a human needs |

> Using an artifact as a cache works and costs money. Using a cache as durable storage works until the day it silently does not.

## What the cache is worth in seconds

We ran the same install on a Latchkey `latchkey-small` runner with the npm cache emptied and then warm, on a lockfile with 414 packages: 4.2 seconds cold, 2.4 seconds warm, 2.3 seconds on a second warm pass. The restored directory was 20 MB. That is a small project, and the ratio matters more than the seconds: the gap scales with the size of the tree and with anything that compiles during install.

Worth knowing before you invest a day in cache keys: on the same runner, in the same script, restoring a Docker layer cache for a small image saved 13.7 seconds on the same commit. If your pipeline builds a container, the layer cache is the bigger number by a wide margin.

> Measured by `content/repro/timings/why-is-github-actions-slow/job-a.sh` on a Latchkey `latchkey-small` runner on 2026-09-20, one pass per row, Node 20.20.2 and npm 10.8.2. The script and its output are committed beside each other.

## Which version of the action to use

The current major is `actions/cache@v6`, and the README lists `v6`, `v5`, `v4` and `v3` as supported. The jump from `v5` to `v6` is not a runtime change, which is the thing people assume: both run on Node 24. `v6` migrated the action to ESM and updated its dependencies. The runtime move happened at `v5`, which is also where the minimum Actions Runner version became 2.327.1, and that is the one worth checking if you host your own runners.

The `save` and `restore` actions published alongside the main one are the ones to reach for when you want to control when the write happens, for example warming a cache on the default branch in a job that does nothing else. Everything in this page about keys, prefixes, immutability and scope applies identically to all of them.

## FAQ

### What is the difference between artifacts and cache in GitHub Actions?

A cache is keyed for reuse by later runs, capped at 10 GB per repository, free and evictable. An artifact is named output, retained for a configured number of days and billed against your plan storage. Lose a cache and the next job is slower; lose an artifact and something that needed it is broken.

### How much cache storage do GitHub Actions include?

10 GB per repository by default, shared across every entry for that repository, and enterprise owners, organization owners and repository administrators can raise it. It is a cap rather than a billed allowance. Cross it and the least recently used entries are removed silently to make room for the new one.

### How long does a GitHub Actions cache last?

GitHub removes entries that have not been accessed in over 7 days, and an entry can go sooner if the repository crosses its size cap. Both happen with no line in any log, which is why a workflow that was fast last month can be slow today with nothing changed in the YAML.

### Why does my cache miss on a pull request?

Caches are scoped by branch. A pull request run restores entries written by its own branch, by the default branch and by its base branch, and never entries written by a sibling branch. If the cache is only ever written on feature branches, every new branch starts cold. Write it on the default branch too.

### Which version of actions/cache should I use?

Use `v6`, the current major as of September 2026. `v5` is still maintained and runs on the Node 24 runtime, which needs Actions Runner 2.327.1 or newer on self-hosted runners. The key, restore-keys and path inputs behave the same across these versions, so upgrading is a version bump rather than a rewrite.

## References

- [GitHub Docs: caching dependencies to speed up workflows (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [actions/cache on GitHub: inputs, outputs and versions (verified 2026-09-20)](https://github.com/actions/cache)
- [GitHub Docs: billing for GitHub Actions storage (verified 2026-09-20)](https://docs.github.com/en/billing/concepts/product-billing/github-actions)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
