GitHub Actions Cache Not Working: Causes and Fixes

Fix GitHub Actions cache misses: unstable keys, empty hashFiles, path and version mismatches, branch scope, failed jobs, and the 10 GB eviction limit.

Published

At a glance

Last reviewed
Versions referenced
actions/checkout@v4 actions/cache@v4 actions/cache/restore@v4 actions/cache/save@v4
Reading time
6 min read

Code samples are not run in a live repository. See our editorial standards.

On this page

When actions/cache reports “Cache not found”, the cause is almost always one of a handful of things: the key, the path, the branch scope, a failed job that never saved, or eviction. Work through them in the order below. Each section says how to confirm the cause.

Confirm what is actually happening

Before changing anything, look at the logs of the cache step and the post-step.

  • The restore step prints Cache not found for input keys: ... on a miss, and Cache restored from key: ... on a hit.
  • The post-step (Post Cache ...) prints Cache saved with key: ..., or Cache hit occurred on the primary key ..., not saving cache.
  • The step output cache-hit is true only on an exact primary-key match. A hit through restore-keys sets it to false, even though files were restored.
  • Set the repository secret or variable ACTIONS_STEP_DEBUG to true for more detail.
  • List existing entries with the GitHub CLI: gh cache list --key node- shows keys, sizes, refs and last-access times. The same list is on the repository’s Actions → Caches page.

If the list contains no entry for your key, the save never happened. If an entry exists but is not restored, the problem is the key, the version, or the scope.

Cache keys that never match

Caches are immutable. Once a key exists, it is never overwritten, so a key must change when the contents should change and stay the same when they should not.

Mistake Effect Fix
github.sha, github.run_id, or a timestamp in key Every run misses on the primary key Hash the lock file instead; use the SHA only as a last-resort suffix
hashFiles() pattern matches nothing Returns an empty string, so the key becomes Linux-node- and is shared by every lock-file state Check the glob; the pattern is relative to GITHUB_WORKSPACE, and files outside it are not hashed
Lock file is not committed or is generated during the job Hash differs between runs or is empty Commit the lock file
Key is longer than 512 characters The step fails Hash long inputs
Multi-line key: (using |) The newline becomes part of the key Write the key on one line

A working Node.js example:

- uses: actions/checkout@v4

- name: Cache npm downloads
  id: npm-cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- run: npm ci

Cache ~/.npm, not node_modules. npm ci deletes node_modules before installing, so a restored node_modules is thrown away. For common ecosystems, actions/setup-node, actions/setup-python, and actions/setup-java have a built-in cache: input that picks sensible keys and paths for you.

Use restore-keys for partial hits

Each restore-keys entry is a prefix. The action tries the exact key first, then each prefix in order, and restores the most recently created entry matching the first prefix that hits. After a prefix hit, the post-step saves a new entry under the exact key, so the next run gets an exact hit.

Order entries from most to least specific, and keep prefixes limited to things that are safe to reuse. A prefix without runner.os can restore a cache built on another OS.

key: ${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-node20-

restore-keys use the same syntax as key. Template syntax from other CI systems, such as {{ checksum "file" }}, is not supported and produces literal text that never matches.

Path and version mismatches

A cache entry has a version computed from the path list, the compression method, and (unless cross-OS archives are enabled) the runner OS. A restore only finds entries with the same key and the same version. If you change path, the old entries stop matching even though the key is identical.

Things to check:

  • The path must exist at save time. If it does not, the post-step warns Path Validation Error: Path(s) specified in the action for caching do(es) not exist and saves nothing.
  • Relative paths resolve against the workspace. ~ expands to the runner’s home directory. Paths differ between Linux, macOS, and Windows runners.
  • Restoring on a different OS than the one that saved needs enableCrossOsArchive: true on both sides. It is rarely worth it; use runner.os in the key instead.
  • Containers: if a job runs in container:, the home directory and tool locations may differ from the host. Cache paths inside the container.

The job failed before the cache was saved

The save happens in the post-step, and it runs only if the job succeeds. A failing test, a cancelled run, or a timeout means nothing is saved, and the next run starts cold again.

If the expensive part is the restore of dependencies and the failure comes later, split the action:

- uses: actions/cache/restore@v4
  id: cache
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

- run: npm ci
- run: npm test

- uses: actions/cache/save@v4
  if: always() && steps.cache.outputs.cache-hit != 'true'
  with:
    path: ~/.npm
    key: ${{ steps.cache.outputs.cache-primary-key }}

Branch and pull request scope

A workflow run can restore caches created on its own ref, its base branch, and the default branch. It cannot restore caches from sibling branches or from other pull requests.

  • A cache saved on a feature branch is invisible to other feature branches and to main.
  • A pull request run has the ref refs/pull/<n>/merge. It can read caches from the base branch, but anything it saves is scoped to that merge ref and is not shared with other PRs.
  • Pull requests from forks follow the same rules: they can restore base-branch caches, and their own saves stay scoped to the PR.
  • Re-runs and workflow_dispatch runs use the ref they were triggered on.

The practical consequence is that the default branch must save the cache that everything else restores from. If main never runs the workflow (for example, it triggers only on pull_request), no PR ever gets a warm cache. Add a push trigger for main:

on:
  push:
    branches: [main]
  pull_request:

Do not add the branch name to the key to “fix” scoping. Scoping is already enforced, and branch-specific keys only fragment the cache and use more of your quota.

Eviction and size limits

GitHub enforces these limits on caches:

  • Entries not accessed for 7 days are deleted.
  • Total cache storage per repository is 10 GB by default. Repository admins on plans that allow it can raise the limit.
  • When the total is exceeded, the least recently used entries are evicted first.

A single large cache, such as a big node_modules or Docker layer cache, can push out every other entry. Symptoms are keys that exist in gh cache list one day and vanish the next, with misses clustering after a large save.

To fix it:

  1. Run gh cache list --sort size_in_bytes --order desc to find the largest entries.
  2. Cache package-manager download directories, not built output or whole workspaces.
  3. Remove stale entries with gh cache delete <key> or gh cache delete --all.
  4. Delete caches for closed PR branches. Each PR run adds entries, and these age out only after 7 days.
  5. Make the key change only when dependencies change, so each push does not add a near-duplicate entry.

Quick checklist

  1. Does the log say Cache saved? If not, check job success and path existence.
  2. Is hashFiles returning an empty string? Print the key in a step to verify.
  3. Did path, OS, or compression change since the entry was saved?
  4. Was the entry saved on a ref the current run can read? Compare the Ref column in gh cache list.
  5. Is the repository close to 10 GB, or has the entry gone unused for 7 days?

Spotted an error? Report it on our Contact page and see our editorial standards for how we correct articles.