In GitHub Actions, how do actions/cache's key and restore-keys differ?
answer
- one is exact, the others are prefixes
- only one of them ever names a saved entry
- the output reports exact matches only
- entries cannot be overwritten in place
- most specific fallback first
basics
~20 skey is the exact identifier looked up first and the name any new cache is saved under. restore-keys is an ordered list of prefixes tried only when key misses, restoring the newest partial match so the job starts from a warm but stale cache.
solid answer
~50 s`actions/cache` first looks for an entry whose key equals `key` exactly. On a hit it restores the files and sets the step output `cache-hit` to `'true'`, and it will not save again at the end of the job. On a miss it walks `restore-keys` in order, treating each as a prefix and restoring the most recent entry that matches; `cache-hit` stays `'false'`, and at the end of a successful job the action saves the current contents under the exact `key`. That asymmetry is the whole design: the key contains a hash of the lockfile so it changes whenever dependencies change, while the restore-keys are a stable prefix that still gets you yesterday's dependency set to build on. Cache entries are immutable, so a key that already exists is never overwritten — the key must move when the content should.
code
yaml · 10 lines- uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
# still run the install: a partial restore only warms the download cache
- run: npm cigo deeper
Know that key identifies the cache exactly and restore-keys are fallback prefixes, and that a lockfile hash belongs in the key so it changes when dependencies change.
Walk the lookup order aloud — exact key, then each prefix newest-first — and explain that cache-hit reports only exact matches while a partial restore still saves under the new key at the end.
Bring in immutability, branch scoping, and eviction: caches are best-effort, keys must move to refresh, and installs should still run so a partial restore cannot produce a wrong dependency tree.
Own the strategy across repositories: what is cached and what is rebuilt, who seeds the default-branch cache, how the repository's cache budget is spent, and how you measure whether caching is actually paying for itself.
## The lookup, step by step A typical dependency cache looks like this: - uses: actions/cache@v4 id: npm-cache with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- When the step runs: 1. **Exact match on `key`.** If an entry with precisely that key is visible to this run, its archive is extracted to `path`, and the step sets `cache-hit` to `'true'`. Because the entry already holds what this key describes, the action does **not** save anything in its post step. 2. **No exact match.** Each entry in `restore-keys` is tried in order as a **prefix**. The most recently created entry whose key starts with that prefix is restored. Files land on disk, but `cache-hit` is `'false'` — it reports exact hits only. 3. **Nothing matched.** Nothing is restored; the job installs from scratch. In cases 2 and 3, if the job succeeds, the action's post step archives `path` and saves it under the exact `key`. ## Why the two-part design The key is a content fingerprint. `hashFiles('**/package-lock.json')` returns a SHA-256 over the matched files, so the key changes exactly when the dependency set changes — correctness. The restore-keys are a stable prefix, so a lockfile bump still restores the previous cache and the package manager only downloads what actually changed — speed. Without restore-keys, every lockfile edit means a cold install; without a hashed key, the cache would never be refreshed at all. Order matters and should run from most to least specific: restore-keys: | ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} ${{ runner.os }}-npm- Include `runner.os` (or the matrix's OS value) in the prefix. Caches restored across operating systems are usually useless and sometimes actively broken, since native modules and paths differ. ## `cache-hit` and conditional steps `cache-hit` is a string, so compare it as one: - if: steps.npm-cache.outputs.cache-hit != 'true' run: npm ci A frequent bug is skipping the install on any restore, including a partial one — that leaves a stale `node_modules` that does not match the lockfile. The safe pattern is to cache the package manager's download directory rather than the installed tree, and always run the install; with a warm cache it is fast and it reconciles the difference. ## Immutability Cache entries are write-once. If the key already exists, the save is a no-op (the log says the cache already exists with that key). You cannot refresh a cache in place — the key must change to produce a new entry. This is why a hardcoded key such as `npm-cache` yields a permanently frozen cache, and why keys are built from a hash of whatever determines the content. ## Scope and lifetime Caches are scoped to a branch. A run can restore entries created on its own ref, and — for a pull request — on the base branch, plus entries from the repository's default branch; it cannot read a sibling feature branch's caches. This isolation is a security property, not an oversight: it stops a branch from poisoning another branch's build inputs. Entries also expire, and a repository has a total cache size budget with least-recently-used eviction when it is exceeded, so a warm cache is a best-effort optimisation and never a correctness dependency. ## Splitting restore and save `actions/cache/restore` and `actions/cache/save` let you separate the two halves — for example, restoring early, and saving only on the default branch so pull-request runs consume the cache without filling the budget with short-lived entries. Useful inputs on the main action include `fail-on-cache-miss` and `lookup-only`. ## The one-sentence answer "`key` is the exact identity and the save name; `restore-keys` are fallback prefixes tried on a miss, giving a warm start without ever preventing the cache from being refreshed."
- After a restore-keys partial match, does actions/cache save a new entry at the end of the job?Yes. A partial restore leaves `cache-hit` as `'false'`, and the post step archives the path and saves it under the exact `key` if the job succeeded. That is how a lockfile bump refreshes the cache: the previous entry warms the install, and the result is stored under the new hashed key.
- Why include runner.os in the cache key and restore-key prefix?Because a cache saved on Linux is at best useless and at worst broken on macOS or Windows — different paths, different native binaries. Without the OS in the prefix, a matrix job would happily restore another platform's archive and then fail in a way that looks like a build bug rather than a cache bug.
- Why is caching the package manager's download directory usually safer than caching node_modules?The download directory is a content-addressed store, so a stale or partial restore is reconciled by simply running the install. An installed tree is state: if it does not exactly match the lockfile and the job skips installation on a partial hit, the build silently runs against the wrong dependency versions.
saying these in an interview costs you the question
- Thinks restore-keys must match a key exactly
- Believes cache-hit is true on a partial restore
- Uses a fixed key and wonders why deps never update
- Skips install on any restore, including partial
- Expects a cache entry to be overwritten in place