Skip to content
Latchkey LogoLatchkey home

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

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.

Diagram: exact key, then restore-keys in order, then a miss, above the 10 GB, 7-day and branch limits
The lookup order and the three limits, from docs.github.com and the actions/cache source, both read on 2026-09-20.

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

LimitWhat GitHub doesWhat it looks like
Total size10 GB per repository by default, raisable by enterprise, organization or repository adminsLeast recently used entries are evicted to make room. One large layer cache can clear out every dependency cache you had
AgeEntries not accessed in over 7 days are removedA repository that is quiet at weekends is cold again on Monday, with no change to the workflow
ScopeA run restores caches from its own branch and the default branch; a pull request also reads its base branchA feature branch cannot read a sibling branch cache, so every new branch starts cold unless the default branch warms it

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.

CacheArtifact
Addressed byA key you computeA name you choose
SurvivesUntil eviction: 10 GB cap or 7 days unreadThe retention period you configure
CostsNothing, but cappedBilled against plan storage
Scoped toBranch, with default-branch fallbackThe workflow run
Use it forDependencies and build caches you can rebuildBinaries, reports and anything a later job or a human needs

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.

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.

Frequently asked questions

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.

Related guides

References

Same key, same restore-keys, one line changed: Latchkey Fast Cache swaps in for actions/cache. Start free → 30-day trial · No credit card