Artifact Cache

An artifact cache stores dependencies or build outputs under a key so a later run restores them instead of rebuilding. How keys, hits and eviction work.

Definition

An artifact cache is a store of dependencies or build outputs, written under a key, so that a later run can restore those files instead of producing them again. A run computes the key, asks the store for a matching entry, and then either restores the files it gets back (a hit) or does the work itself and writes a new entry (a miss).

The name covers two kinds of content that behave the same way. The first is dependencies downloaded from a package registry, such as an npm store, a Go module cache, a Maven repository, or a Cargo registry. The second is outputs a build tool produced from source, such as compiled objects, a Rust target directory, or a Gradle build cache. Both are expensive to create and cheap to copy, which is the property that makes caching worthwhile.

The four parts of a cache entry

Every artifact cache, in any tool, is described by the same four things.

Paths. The files and directories that get packed into the entry. Only files named by path can be cached, so state that lives somewhere else, such as an installed system package or a running daemon, is out of reach of a cache step.

The key. A string computed by the run that identifies the content. A good key changes exactly when the content should change. The usual construction is a hash of the lockfile that determines the dependency set, prefixed by the platform, so that a dependency bump produces a new key and everything else reuses the old one.

The store. The service that holds entries and answers lookups by key. It sits outside the machine running the job, which is why a fresh machine can still start warm.

Retention. The rule that decides when an entry disappears. Retention is what separates a cache from durable storage, and it is the part teams forget until a key that used to hit starts missing.

Hits, misses, and partial restores

A lookup has three outcomes. An exact hit means an entry existed under the primary key, and the restored files are the ones that key describes. A miss means nothing matched, so the job does the full work and usually saves a new entry at the end. A partial restore sits in between: no entry matched the primary key, so the store fell back to the newest entry whose key starts with a prefix the job supplied, and the job restores something close to what it wanted and reconciles the difference.

That middle outcome is the one that causes confusion. A partial restore still leaves useful files on disk, so the job does less work than a cold run, and the install step still has real work to do. A run with an exact hit is often called warm and a run with nothing to restore is called cold.

A cache entry and a build artifact are different objects

Both words get used loosely, and the difference is about guarantees. A cache entry is a copy of something the build can recreate, so the store is allowed to throw it away. An artifact is an output that is kept on purpose for a stated period, so that a person can download it or a later job can consume it.

The table below states the difference in terms of the rules GitHub Actions applies. The cache numbers come from GitHub's caching documentation and the artifact numbers from the GitHub Actions limits reference, both checked on 2026-08-13.

PropertyCache entryBuild artifact
PurposeSkip work a later run would repeatKeep an output for people or for later jobs
Addressed byA key the workflow computesA name given at upload time
Default lifetimeDeleted after 7 days without useRetained 90 days
Configurable lifetimeExpiration policy set by repository or organization admins1 to 90 days on public repositories, 1 to 400 days on private and internal repositories
Removed early whenThe repository passes its size cap, and least recently used entries are evicted firstAn admin or a workflow deletes it
Guaranteed presentNoYes, until the retention period ends
Effect of absenceThe job rebuilds and takes longerThe download step fails

The practical rule follows from the last row. A build has to succeed with an empty cache store, every time, on every branch. Once a pipeline depends on an entry being present, the cache has quietly become storage, and the next eviction becomes an outage. Anything a later job truly requires belongs in an artifact upload with a retention period set on purpose.

Scope is part of the identity

A key alone does not identify an entry. In GitHub Actions the lookup also carries a version, which hashes the compression tool used and the exact list of cached paths, and a branch scope: a run can read entries created on its own branch and on the branch it was created from, and sibling branches stay invisible to each other. Two jobs can therefore use the same key string and still see different entries. Platform belongs in the key for the same reason, because a dependency tree installed on Linux is rarely usable on macOS.

Example

Here is the shape almost every Node repository uses. The primary key pins the exact lockfile, and restore-keys supplies one prefix to fall back to.

name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

Follow three runs through that step.

Run 1, a new repository. hashFiles produces a digest of package-lock.json, so the key resolves to something like Linux-npm-9f2c.... Nothing matches the key, and nothing matches the prefix Linux-npm-, so the step restores nothing and reports a miss. npm ci downloads every package. At the end of the job the action packs ~/.npm and saves it under the primary key.

Run 2, no dependency change. The lockfile is byte for byte the same, so the key is the same string, and the store returns that entry. This is an exact hit: npm ci runs against a populated download cache and skips the network for every package. No save happens at the end, because an entry already exists under that key.

Run 3, after a dependency bump. One package moved, so the lockfile changed and the key becomes Linux-npm-4a71.... No entry matches. The prefix Linux-npm- does match the entry saved in run 1, and the store returns that one, which is a partial restore. Most packages are already on disk, npm ci fetches only the difference, and the job saves a new entry under the new key. The old entry stays until retention removes it.

The distinction between run 2 and run 3 shows up in one output. cache-hit is true only for the exact match in run 2. After the partial restore in run 3 it is false, which is correct, because the restored files describe an older lockfile. A step guarded with if: steps.npm-cache.outputs.cache-hit != 'true' therefore still runs after a fallback, and that is the behavior you want: skipping the install on any restore is how stale dependencies reach a build.

Restore attemptKey stateWhat the store returnscache-hitSave at end of job
Run 1, empty storeNo exact match, no prefix matchNothingfalseWrites an entry under the primary key
Run 2, lockfile unchangedExact match on the primary keyThat entrytrueSkipped, the key already exists
Run 3, lockfile changedNo exact match, prefix matches an older entryThe most recent prefix matchfalseWrites an entry under the new primary key

The cache step is independent of which machine runs the job. Changing the runs-on label moves the same steps onto a different fleet, and the key, the fallback, and the three outcomes above behave the same way:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

One boundary is worth stating, because it surprises people who move a job across platforms. An entry saved by a job on warp-macos-15-arm64-6x will never restore on warp-ubuntu-latest-x64-4x. The entry version hashes the compression tool and the cached paths, and runner.os in the key differs as well, so the two platforms address separate entries by construction. Cache inputs, outputs, key scoping, and the requirements for running a cache step inside a container are documented on the caching documentation page.

Two habits keep this pattern healthy over time. Keep the number of distinct prefixes small, because every prefix is a separate family of entries competing for the same retention budget. And seed the shared entry from the default branch, since branch scoping lets pull request branches read entries created on the branch they came from, which avoids one near identical copy per active branch.

FAQ

What is an artifact cache?

An artifact cache is a store of dependencies or build outputs written under a key, so that a later run can restore those files instead of producing them again. A run computes the key, asks the store for a matching entry, and either restores the files or does the work and writes a new entry.

What is the difference between a cache and an artifact?

A cache entry is an optimization that the store may remove at any time, so a build has to succeed when the store is empty. An artifact is an output kept for a stated period so that people or later jobs can download it. In GitHub Actions a cache entry is deleted after 7 days of disuse and can be evicted earlier when a repository passes its size cap, while an artifact is retained for 90 days by default.

What is the difference between an exact cache hit and a partial restore?

An exact hit means an entry existed under the primary key and the restored files match what the key describes. A partial restore means no entry matched the primary key, so the store fell back to the newest entry matching a restore-keys prefix. The cache-hit output is true only on an exact hit, which is why install steps should run after a fallback restore.

Why did a cache key that hit yesterday miss today?

Either the key changed or the entry is gone. Keys built from a lockfile hash change whenever a dependency moves, and entries disappear when they age past the disuse window or when the store evicts them to stay under a size cap. Cache scope also matters, because an entry saved on one branch, operating system, or set of paths is a different entry from the one a job on another branch or platform asks for.

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.