# Cache hit occurred on the primary key, and nothing is ever saved again

> Cache hit occurred on the primary key explains a cache that never updates: a static key restores forever and the post step stops writing.

Source: https://latchkey.dev/learn/github-actions/github-actions-cache-key-collision-immutable-not-updating  
Updated: 2026-09-20

Cache hit occurred on the primary key is an info line from the post step of `actions/cache`, and it means the step has decided not to upload anything. On a key that never changes, the content saved by the first run that ever succeeded is the content every later run restores.

## What this error means

Nothing is red. The restore step says the cache was restored, the job runs, and the post step at the very bottom of the log writes one line saying it is not saving. Weeks later a dependency that was upgraded in the lockfile is still the old version on CI and only on CI, or a compiled output keeps coming back with a change in it that was reverted. The two lines that explain it are separated by the whole job, and the second one is collapsed inside the post step, which is the part of a run almost nobody expands unless something has already gone wrong.

```Reconstructed from src/restoreImpl.ts and src/saveImpl.ts in actions/cache v6.1.0; the ellipsis stands for the job between the two steps
Cache restored from key: deps-v1
...
Post job cleanup.
Cache hit occurred on the primary key deps-v1, not saving cache.
```

## Common causes

### The key is a constant

A key like `node-modules` or `deps-cache` with nothing interpolated into it is an exact match forever. The first run that completed a save decided the contents, and every run since has restored them and declined to write. This is the overwhelming majority of the cases.

### The key interpolates something that does not change

A key built from `runner.os` and the repository name looks dynamic and is not. The same is true of a key built from a version string in a file that gets edited once a year. If the interpolated value is stable across the change you care about, the key is static with respect to that change.

### hashFiles is pointed at a file that is not the input

Hashing `package.json` rather than `package-lock.json` is the usual version of this. The manifest can stay byte-identical across a lockfile update, so the key does not move, so the post step keeps declining to save while the dependency tree underneath it has changed.

### The path being cached is not the path that changed

Less common, and worth ruling out before you redesign the key. If the cached path is a package manager store and your problem is in a build output directory that is not cached at all, the key is irrelevant and nothing on this page will help.

## How to fix it

### Derive the key from the file that decides the content

Put `hashFiles` over the lockfile, not the manifest, and add a prefix as `restore-keys` so a new hash still starts from the previous entry rather than from nothing. This is the whole fix in most repositories, and it removes the manual version number rather than incrementing it.

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

### Confirm the diagnosis in one run before you change the key

1. Open the most recent run and expand the last step, named Post followed by the cache step name.
2. Look for the line reading Cache hit occurred on the primary key.
3. If it is there, the key is static with respect to whatever you changed; if it says the cache was saved, the key is fine and your problem is elsewhere.
4. Compare the key in that line against the key in the run from before your change. If they are identical, that is the bug.

### Split restore and save when you have to write under a fixed key

Some workflows genuinely need a fixed name, for example a warm cache that one scheduled job maintains for everyone else. Use the restore and save actions separately: the readers only restore, and the single writer saves under a key it derives fresh each time, so no job is ever in the position of restoring and re-saving the same key.

```.github/workflows/ci.yml (illustrative)
- uses: actions/cache/restore@v6
        with:
          path: ~/.cache/build
          key: warm-${{ github.ref_name }}-
          restore-keys: warm-

      # in the scheduled job only
      - uses: actions/cache/save@v6
        with:
          path: ~/.cache/build
          key: warm-${{ github.ref_name }}-${{ github.run_id }}
```

### Stop treating deletion as a fix

Deleting the entry from the repository cache list gives you one correct build and then the same bug. If you have deleted a cache to fix this more than once, that is the signal the key needs to change rather than the entry. Note the key you deleted, fix it, and check the post step on the next two runs.

## How to prevent it

- Never write a cache key with nothing interpolated into it.
- Hash the lockfile, not the manifest, and keep the prefix in `restore-keys`.
- Read the post step on the first run after any cache change, not the restore step.
- If a cache has been deleted by hand twice, treat the key as the defect.

## The comparison that stops the upload

The whole behavior is four lines near the top of the post step. It reads the key the restore step stored in state, compares it with the primary key, and returns if they are the same. `saveCache` is never called, so no archive is built and no request reaches the cache service.

That is correct. Uploading an identical key would be rejected anyway, because cache entries cannot be replaced once written. The problem is not the check, it is that on a static key the check is true on every run after the first, which converts a cache into a snapshot of a machine that existed months ago.

```actions/cache, src/saveImpl.ts (v6.1.0)
const restoredKey = stateProvider.getCacheState();

if (utils.isExactKeyMatch(primaryKey, restoredKey)) {
    core.info(
        `Cache hit occurred on the primary key ${primaryKey}, not saving cache.`
    );
    return;
}
```

## Three key shapes and what the post step does with each

A key that changes with the content is the shape the action is designed around, and it is the only one of the three where the post step writes anything after the first run. The restore-keys line is what stops that from being expensive: a new hash misses the exact key, falls back to the prefix, restores the nearest previous entry, and then saves a fresh one under the new hash.

The middle row is where most real workflows sit without meaning to. It works, in the sense that the job is green and something is restored, and it is wrong in a way that only shows up as a stale dependency weeks later.

| Key shape | What the restore does | What the post step does |
| --- | --- | --- |
| `deps-v1` | exact hit on every run after the first | returns, writes the not saving line |
| `deps-${{ hashFiles(...) }}` | exact hit while the lockfile is unchanged | returns while unchanged, uploads when the hash moves |
| `deps-${{ github.run_id }}` | never an exact hit, restore-keys or nothing | uploads on every single run |

## Why bumping the key is the fix and clearing it is not

The instinct on finding a stale cache is to delete it from the repository cache list and let the next run rebuild it. That works exactly once. The key is still static, so the next run saves fresh content under it and the run after that is back to restoring a snapshot. You have bought one good build.

Editing the key to `deps-v2` is the same trade with more ceremony: it is a manual version number that has to be bumped by whoever remembers, which in practice means it is bumped when somebody has already lost a day. Deriving the key from a hash of the inputs moves that decision to the thing that actually changed.

There is one other way a static key eventually refreshes itself, and it is not a plan. Entries that have not been read for seven days are evicted, and the repository is capped at 10 GB with least recently used entries removed to make room. A cache nobody reads for a week disappears and gets written again, which is why this bug sometimes appears to fix itself on a quiet repository and never on a busy one.

> If your problem is the opposite, a cache that is never found at all rather than one that never changes, start with [GitHub Actions cache not restored](/learn/speed/github-actions-cache-not-restored).

## Why there is no recorded run on this page

A recorded run cannot show this. The thing the page is about is the absence of a second upload, and absence does not photograph: one run restores and does not save, which is also what a perfectly healthy cached job does when its lockfile has not changed. Telling the two apart needs two runs with a content change between them and a comparison of what came back, which is an argument about a sequence rather than a screenshot of a job.

It is also not a failure state a runner could detect at the time. Both runs exit zero and the log line that gives it away, the one quoted above, is written at `info` level by a step that is behaving exactly as designed. There is nothing to retry and nothing that went wrong in the moment.

## FAQ

### Can you overwrite a GitHub Actions cache entry?

No. Entries are immutable once written, which is why the post step checks for an exact key match and returns instead of trying. The way to change what is cached is to write under a different key, which normally means putting a hash of the inputs in it.

### What does "Cache hit occurred on the primary key" mean?

It is the post step saying it has nothing to do. The restore step matched your key exactly, so the content is already stored under that key, so uploading it again would be rejected. On a key that never changes, this line appears on every run and no new content is ever saved.

### Will deleting the cache fix a cache that never updates?

Only for one run. The next run saves fresh content under the same static key and the one after that is stale again. Deleting is a way to confirm the diagnosis quickly, not a fix; the fix is to make the key move when the content moves.

### Does a restore-keys match stop the post step from saving?

No, and this is the difference that makes restore-keys safe. The post step compares the restored key with the primary key and only returns when they are equal. A prefix match restores something useful and still leaves the primary key unwritten, so the run saves a fresh entry.

## References

- [actions/cache src/saveImpl.ts: the exact-key comparison and the line it writes](https://github.com/actions/cache/blob/main/src/saveImpl.ts)
- [actions/cache README: the 10 GB repository cap and the seven-day eviction rule](https://github.com/actions/cache#cache-limits)
- [actions/cache/restore and actions/cache/save: using the two halves separately](https://github.com/actions/cache/tree/main/save)
- [Dependency caching reference: key, restore-keys and cache key matching](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

---

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
