How Do I Write a Good Cache Key in GitHub Actions?
Build the key from the runner platform, the toolchain version, and a hash of the lockfile, then list shorter prefixes of that same string in restore-keys.
Build the key from three parts joined with hyphens, ordered from most general to most specific: the runner platform, the toolchain and its version, and a hash of the lockfile that decides what gets installed. Then list shorter prefixes of that same string in restore-keys, so a run whose lockfile changed still restores the closest previous entry instead of starting cold.
Answer
The template, and one concrete instance of it for an npm download cache:
<platform>-<arch>-<ecosystem>-<tool version>-<content hash>
${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }}Each segment earns its place by answering one question: if this thing changes, is the stored tree still the tree I want?
| Segment | Example value | Include it because | Drop it and |
|---|---|---|---|
| Platform | ${{ runner.os }} renders Linux | Binaries and paths differ across operating systems | A macOS entry can be handed to a Linux job through a prefix match |
| Architecture | ${{ runner.arch }} renders X64 or ARM64 | Compiled native modules are architecture specific | An x64 tree lands in an ARM64 job and fails at run time |
| Ecosystem | npm | One repository caches several unrelated trees | The prefix ladder crosses between a pip cache and an npm cache |
| Tool version | node-22 | A toolchain bump changes the layout of what is stored | A Node 22 job restores a tree built by Node 20 |
| Content hash | ${{ hashFiles('**/package-lock.json') }} | This is the segment that turns the entry over | The key stops tracking the contents entirely |
runner.os and runner.arch come from the GitHub contexts reference. hashFiles returns a single SHA-256 over the matched file set, and an empty string when the pattern matches nothing (GitHub expressions reference), which is why a cache step placed before actions/checkout produces a key ending in a bare hyphen that every run of the workflow shares.
The ladder is the same string with the most specific segment removed, one rung at a time:
key: ${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-npm-node-22-
${{ runner.os }}-${{ runner.arch }}-npm-node-GitHub documents the matching rule directly: "When a key doesn't match directly, the action searches for keys prefixed with the restore key. If there are multiple partial matches for a restore key, the action returns the most recently created cache" (GitHub dependency caching reference, checked on 2026-08-13). Rungs are searched in the order you write them, so the list runs from narrow to wide. Keys have a maximum of 512 characters and a longer key fails the step (GitHub dependency caching reference), which the three-segment shape stays well inside.
Two properties of the key are worth knowing before you tune one. The key is only half of an entry's identity, because the cache action also derives a version from the path list and the compression tool on the machine, so two jobs with identical key text and different paths address different entries (WarpBuild caching documentation). And cache-hit is true only on an exact primary-key match, so an install step guarded with if: steps.cache.outputs.cache-hit != 'true' still runs after a prefix restore, which is the behavior you want. Cache key covers both in full.
Detail
The two failure shapes
A key fails in one of two directions, and they look nothing alike in the job log.
Too specific: the key never hits. A key that ends in ${{ github.sha }}, a timestamp, or ${{ github.run_id }} changes on every run by construction. The restore step reports a miss, the install runs cold, and the save step writes an entry nobody will ever ask for again. The tell is a cache section in the log that shows a write on every run and a hit on none.
Too loose: the key restores a stale tree. A key such as ${{ runner.os }}-npm with no hash never changes, and cache entries are immutable, so the save step writes nothing once an entry exists under that key and version (actions/cache README). The first tree ever written is served to every run afterwards. The tell is a hit rate near 100 percent with dependency versions that do not match the lockfile, and a failure that reproduces in the pipeline and never locally.
The same two shapes show up in the ladder rather than the primary key. A primary key carrying runner.arch with a restore-keys rung that drops it behaves correctly until the exact key misses, which is the run where the lockfile changed and the diff already looks large enough to explain anything.
Both shapes have a price attached. WarpBuild bills cache storage at $0.20 per GB-month and $0.0001 per cache write or restore (pricing page, checked on 2026-08-13), and an entry expires 7 days after its last use (WarpBuild caching documentation). Take a repository running 1,200 workflow runs a month over a 0.8 GB dependency tree, with a lockfile that changes 8 times in the month:
| Key shape | Distinct entries alive | Storage per month | Operations per month | What a restore does |
|---|---|---|---|---|
| Lockfile hash plus two prefixes | up to 8, so 6.4 GB | $1.28 | 1,208 ops, $0.12 | Exact hit while the lockfile holds, prefix hit on the run that changes it |
| Commit SHA | about 280, so 224 GB | $44.80 | 2,400 ops, $0.24 | Never an exact hit, and every run pays a full cold install |
| Static string, no hash | 1, so 0.8 GB | $0.16 | 1,200 ops, $0.12 | Always a hit, always the first tree that was ever written |
The SHA row assumes the steady state a never-reused key reaches: about 40 runs a day, each entry sitting for its 7 days before it expires, so roughly 280 entries are resident at any moment. Storage is the smaller half of that bill. The bigger half is the install time every run repeats, which you can price from your own duration numbers at the per-minute rate for your runner label on the pricing page.
Split the restore and the save
The single-action form saves in a post-job step, which runs after the job finishes and skips the save when an earlier step fails (actions/cache save README). Splitting the action into restore and save puts the write where you want it, which keeps a slow install from being repeated because a fast test failed:
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Restore npm cache
id: npm-cache
uses: WarpBuilds/cache/restore@v1
with:
path: ~/.npm
key: ${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-npm-node-22-
${{ runner.os }}-${{ runner.arch }}-npm-node-
- run: npm ci
- name: Save npm cache
if: always() && steps.npm-cache.outputs.cache-hit != 'true'
uses: WarpBuilds/cache/save@v1
with:
path: ~/.npm
key: ${{ steps.npm-cache.outputs.cache-primary-key }}
- run: npm testThree details carry the behavior. The save step sits before npm test, so a failing test suite leaves a warm cache behind. always() keeps the save running when an earlier step failed, which suits a package download cache because the package manager refetches whatever is missing; for a cache of compiled output, guard the save on the build step succeeding instead. And cache-primary-key replays the exact key the restore step computed, so the entry is written under the key the next run will ask for, rather than under whichever prefix happened to match. WarpBuilds/cache@v1 and its restore and save subactions are drop-in replacements for actions/cache@v4 with the same path, key, and restore-keys inputs (WarpBuild caching documentation).
Two platform facts constrain where this runs. WarpBuild caching is not supported on Windows runners (WarpBuild caching documentation), so a Windows leg of a matrix keeps actions/cache@v4. For toolchains and package managers, the cache-enabled setup actions for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise compute the key themselves, which removes this whole problem for the paths they cover.
A well-written key stops helping when the state you want back is machine state rather than a list of directories: a warm daemon, a container image store, an incremental build tree with its timestamps intact. The guide to persistent caches on GitHub Actions draws the boundary between the two mechanisms and shows when to run both.
Related Questions
How many restore-keys should I list?
Two is enough for most workflows and three is the practical ceiling. Every rung has to be a literal prefix of the primary key, and every rung has to be narrow enough that it can only match entries holding the same kind of tree. A rung that widens to the platform segment alone will happily restore a Python cache into a Node job. What restore-keys are in GitHub Actions works through how competing prefixes resolve.
Should a cache key include the commit SHA?
Only when the cached tree is incremental build output and the workflow lists prefixes underneath it. A SHA-keyed entry never produces an exact hit, so every run writes a new entry and pays storage for it until the entry expires 7 days after its last use. On a dependency cache, hash the lockfile instead. Why a GitHub Actions cache misses every run covers the other causes of a permanent miss.
Do I need to write a cache key at all?
Not for language toolchains and package-manager dependencies. The WarpBuild setup actions for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise are drop-in replacements for their upstream counterparts and manage the key themselves, keyed on the lockfile the ecosystem already declares. Hand-written keys are for paths those actions do not cover, which the guide to persistent caches on GitHub Actions lists alongside the cases where a cache action is the wrong tool. Cache key has the underlying definition.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.