Skip to content
Latchkey LogoLatchkey home

GitHub Actions cache not restored: the four silent misses

A GitHub Actions cache not restored is almost never a broken cache service. It is one of four things: a key that is different on every run, a restore-key prefix that matched an entry you did not want, nothing saved anywhere this branch can read, or a save the service refused. All four exit zero.

Four panels: no match, a stale prefix match, nothing saved in scope, and a refused save, with log lines
The four misses and the line each one prints. Strings quoted from the actions/cache and actions/toolkit sources, read on 2026-09-20.

What this error means

The install step that a cache was supposed to shorten runs at full length, and nothing in the job is marked failed. The evidence is one line, either in the restore step or in the post-job step, and the exit code is zero whichever line you got. Which line it is happens to be the entire diagnosis, so it is worth reading before you change a single key.

Restore step on a miss (actions/cache src/restoreImpl.ts, example keys)
Cache not found for input keys: npm-Linux-a91f4c2e7b, npm-Linux-

Tell the four apart before you change a key

The restore step and the post-job step print a different line for each failure, and reading which one you have takes ten seconds. Cache not found for input keys: means nothing matched, exact or prefix. Cache restored from key: naming something other than your exact key means a restore-key matched and the content may be old. Cache hit occurred on the primary key ..., not saving cache. means the entry already existed and your new content was not stored. Unable to reserve cache with key means the write was refused.

Print the step output as well. The action documents cache-hit as a string, not a boolean: true when the exact key matched, false when a restore-key matched instead, and an empty string when nothing matched at all. A workflow that tests it as a boolean collapses the last two into one, which is exactly the distinction you need here.

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

# 'true' = the exact key hit; 'false' = a restore-key hit; '' = nothing matched
- run: echo "cache-hit: '${{ steps.cache.outputs.cache-hit }}'"

Common causes

The key is different on every run

A key built from github.sha, github.run_id or a timestamp is unique per run, so the entry each run writes can never be found by any later run. This is the most common form by a distance, and it is invisible because every run does successfully save a cache. They just save one nobody will ever ask for again.

A restore-key prefix matched something you did not want

Restore-keys are prefix fallbacks, and a broad one such as npm- matches the most recent entry from any OS, any branch that wrote it and any lockfile state. The step reports a restore, the files arrive, and they are three weeks old. Nothing in the log calls this a problem, because from the action's point of view it is not one: you asked for a prefix and it gave you the newest thing that started with it.

Nothing was saved where this branch can read it

Three versions of one hole. The exact key hit, so the post-job save was skipped by design and content that changed under that unchanged key was never stored. Or the job that would have written the entry failed, because the save only runs after a successful job. Or the entry exists somewhere this run cannot see: a run reads its own branch and the default branch, a pull request also its base, and never a sibling branch.

The save was refused

The entry already exists and entries are immutable, or two matrix legs finished close together and raced for the same key. The action reports it as a warning, Unable to reserve cache with key, and the job stays green, so the place most people first meet this is a later run that should have been warm and was not. A re-save under a key that already exists is not an overwrite, it is a no-op.

How to fix it

Key on the lockfile, never on the commit

Hash the files that determine the contents, so the key is stable across runs that changed nothing and changes exactly when the contents should. Add a restore-key specific enough to match only a compatible entry, and keep the install step after the restore so a partial hit is reconciled rather than trusted.

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

- run: npm ci

Narrow the prefix until it can only match something compatible

  1. Put every dimension that must match into the prefix, not only into the exact key: the tool, the runner image and the major version of the runtime.
  2. Keep the prefix one segment shorter than the exact key, so it falls back to the previous lockfile and nothing older in kind.
  3. Run the install after the restore regardless of whether the hit was exact, so a partial restore is brought up to the lockfile instead of being used as is.
.github/workflows/ci.yml
key: npm-${{ matrix.os }}-node20-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  npm-${{ matrix.os }}-node20-

Warm the cache where every branch can read it

Write the entry on the default branch so pull requests have something in scope to restore. A small scheduled job that restores and saves is enough, and it also keeps the entry inside the 7-day window on a repository that goes quiet.

.github/workflows/warm-cache.yml
on:
  schedule:
    - cron: '0 6 * * 1-5'
  push:
    branches: [main]

jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      - run: npm ci

Give every matrix leg its own key, and roll the key to refresh

Two legs racing to save the same key produce Unable to reserve cache with key, which is a warning and not a failure, and one leg's content is lost. Put the matrix dimensions in the key. When you genuinely need to discard an entry, bump a version prefix rather than trying to overwrite it: entries are immutable, so a re-save under an existing key does nothing.

.github/workflows/ci.yml
key: v2-deps-${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  v2-deps-${{ runner.os }}-${{ matrix.node }}-

A note on where these log lines come from

The four lines below are verbatim from the action's own source, read on 20 September 2026, with example keys filled in. No single run prints all four, so the block is a reference rather than a transcript, and we would rather say that than imply otherwise.

The Actions cache service is reachable only from inside a GitHub-hosted job, so no script on any other runner can produce these lines. This page therefore carries no reproduction of its own.

The four lines, with example keys, and the file each is quoted from
Cache not found for input keys: npm-Linux-a91f4c2e7b, npm-Linux-
# actions/cache, src/restoreImpl.ts

Cache restored from key: npm-Linux-3d0be71c4a
# actions/cache, src/restoreImpl.ts

Cache hit occurred on the primary key npm-Linux-a91f4c2e7b, not saving cache.
# actions/cache, src/saveImpl.ts

Unable to reserve cache with key npm-Linux-a91f4c2e7b, another job may be creating this cache.
# actions/toolkit, packages/cache/src/cache.ts

Verify the fix on the next run, not the one after

A cache change takes two runs to prove: one to write the entry under the new key, one to restore it. Judging a fix on the first run is how a correct change gets reverted. Run it on the default branch first, then open a pull request and check that the restore step names your exact key and that cache-hit is true.

If the second run still misses, print the key itself. An expression that resolves to an empty string, a hashFiles glob that matches no files, or a path that differs between save and restore all produce the same silent miss, and all three show up the moment the key is echoed.

.github/workflows/ci.yml
- run: |
    echo "key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
    # an empty hash here means the glob matched nothing

How to prevent it

  • Echo the resolved key the first time you write one, so an empty hashFiles result shows up that day rather than three weeks later.
  • Put the runner image in the key, not only runner.os, whenever what you cache is compiled against system libraries.
  • Judge a cache change over two runs, one to write the entry and one to restore it, before deciding it worked.
  • Treat an entry as write-once. To refresh it, bump a version prefix and let the old one age out.

Frequently asked questions

Why does actions/cache never find a matching key?
Because the key it looks for is not the key anything saved. The usual reason is a key built from github.sha or a timestamp, which is unique per run. The second most common is a hashFiles glob that matches nothing, which resolves to an empty string. Echo the key in the job and compare it with the one in the restore step line.
My cache was restored but cache-hit is false. Is that a problem?
It means a restore-key prefix matched rather than your exact key, so the content is from an earlier state of the project. It is not an error, and it is a problem when you skip the install on the strength of it. Keep the install step unconditional, and narrow the prefix if the entry it falls back to is too old to be worth restoring.
Why is my cache not being saved?
Most often because the exact key already exists: entries are immutable, so the action logs that a hit occurred on the primary key and skips the save. The other reason is that the job failed, because the post-job save only runs after a successful job. If the content changed but the key did not, put the thing that changed into the key.
Does changing the runner image invalidate my cache?
Between Ubuntu majors, no, and that is the hazard rather than the relief. The cache version hashes the path and the compression tool, not the image, so an entry saved on ubuntu-22.04 restores on ubuntu-24.04 whenever the key matches and hands you a tree built against different system libraries. runner.os is Linux on both and cannot separate them. Put the image in the key with matrix.os or ImageOS if that matters to what you cache.

Related guides

References

Fix the key first. Latchkey Fast Cache then takes the same key and restore-keys in one line. Start free → 30-day trial · No credit card