---
title: "GitHub Actions cache: dependencies, keys, and cache hits"
description: "Configure the GitHub Actions cache for npm, pnpm, yarn, pip, Cargo, Go, and Gradle. Learn cache keys, restore keys, cache hits, and cache-dependency-path."
url: https://starsling.dev/best-practices/github-actions/cache-dependencies
canonicalUrl: https://starsling.dev/best-practices/github-actions/cache-dependencies
---

# GitHub Actions cache: dependencies, keys, and cache hits

[GitHub Actions best practices](https://starsling.dev/best-practices/github-actions) / GitHub Actions cache: dependencies, keys, and cache hits

- [How StarSling works](https://starsling.dev/)

Best practice: Caching & Setup. Last updated: 2026-10-06

The GitHub Actions cache reuses package-manager downloads between workflow runs. Use a setup action's `cache:` input for standard dependency stores, or `actions/cache` when you need an explicit path and key; in both cases, derive the key from the lockfile so unchanged dependencies restore cleanly.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Shipped by StarSling](#shipped-by-starsling)
- [How GitHub Actions cache keys and restore keys work](#github-actions-cache-keys-and-restore-keys)
- [Point the cache at the right lockfile: cache-dependency-path](#cache-dependency-path-lockfile)
- [Troubleshoot GitHub Actions cache misses](#troubleshoot-github-actions-cache-misses)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Optimization recipes](#optimization-recipes)
- [More ways to improve GitHub Actions](#speedup-guides)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

A warm workflow restores the dependency store from the GitHub Actions cache before the install step. The setup action's `cache:` input owns the path and key for common package managers; an explicit `actions/cache` step exposes `path`, `key`, and optional `restore-keys` for custom stores and build outputs. Most ecosystems have a first-class hook: `cache:` on `actions/setup-node` (npm, pnpm, yarn), `actions/setup-python` (pip, pipenv, poetry), `actions/setup-go` (the module and build caches), and `actions/setup-java` (maven, gradle). Set that input explicitly rather than relying on a default: `actions/setup-node` v5 began auto-caching when it detected a package manager, and v6 narrowed that to npm only (and only when `package.json` names npm in `packageManager` or `devEngines.packageManager`), so a pnpm or yarn repo that never sets `cache:` gets no cache at all. Cargo has no built-in hook, so key an `actions/cache` step on `Cargo.lock` yourself.

_Cache keyed on the lockfile, per ecosystem_

```yaml
# Take the block for your ecosystem. Each one keys the cache on the lockfile,
# so an unchanged lockfile restores instead of reinstalling.
steps:
  - uses: actions/checkout@v7

  # Node (npm / pnpm / yarn): setup-node caches the package manager's store.
  # From setup-node v6, only npm is cached automatically (and only when
  # package.json names npm in "packageManager"). pnpm and yarn still need
  # this explicit cache: input.
  - uses: pnpm/action-setup@v6
    with:
      version: 10   # required unless package.json sets "packageManager"
  - uses: actions/setup-node@v6
    with:
      node-version: 22
      cache: pnpm                  # keyed on pnpm-lock.yaml
  - run: pnpm install --frozen-lockfile

  # Python: setup-python caches the pip download cache
  - uses: actions/setup-python@v6
    with:
      python-version: "3.13"
      cache: pip                   # keyed on requirements.txt

  # Go: setup-go caches the module + build cache, keyed on go.sum
  - uses: actions/setup-go@v6
    with:
      go-version: "1.24"
      cache: true

  # Java: setup-java caches the Gradle or Maven dependency store
  - uses: actions/setup-java@v5
    with:
      distribution: temurin
      java-version: "21"
      cache: gradle                # or: maven

  # Rust: no built-in hook. Cache the resolved registry and the build dir, keyed on
  # Cargo.lock. Copy actions/cache's Rust example and note the two traps.
  - uses: actions/cache@v6
    with:
      # Not the whole ~/.cargo/registry: registry/src and git/checkouts are unpacked
      # from the cached archives, so caching them stores the same bytes twice.
      path: |
        ~/.cargo/bin/
        ~/.cargo/registry/index/
        ~/.cargo/registry/cache/
        ~/.cargo/git/db/
        target/
      key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
      # NO restore-keys here, deliberately. A prefix fallback restores a target/ built
      # against a DIFFERENT Cargo.lock, then saves it again under the new key: the stale
      # artifacts never get evicted and the cache grows without bound. A cache miss on a
      # dependency change is the correct outcome.
```

<a id="avoid-this"></a>

## Avoid this

Every run pays the full install cost, and it multiplies across every matrix leg and shard.

_No cache: full install every run_

```yaml
steps:
  - uses: actions/checkout@v7
  - uses: pnpm/action-setup@v6
    with:
      version: 10   # required unless package.json sets "packageManager"
  - uses: actions/setup-node@v6
    with:
      node-version: 22       # no cache: key, and setup-node never auto-caches
                             # pnpm, so nothing is restored
  - run: pnpm install        # re-downloads every dependency, every run
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Django, Infisical, and Immich restore package-manager or browser caches before install and browser setup, so dependency downloads and Playwright installs are reused instead of repeated.

### Infisical/infisical - .github/workflows/validate-db-schemas.yml

```yaml
              with:
                  node-version: "22"
                  cache: "npm"
                  cache-dependency-path: backend/package-lock.json
```

Key lines: `cache: "npm"`, `cache-dependency-path: backend/package-lock.json`.

A monorepo wrinkle the clean example above leaves out: cache-dependency-path tells setup-node which lockfile to key the cache on.

Source: [Infisical/infisical `.github/workflows/validate-db-schemas.yml` lines 34-37](https://github.com/Infisical/infisical/blob/931fe8fe6892b79cfbec472c289da93355139048/.github/workflows/validate-db-schemas.yml#L34-L37) at commit `931fe8f`.

### django/django - .github/workflows/python_matrix.yml

```yaml
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
```

Key line: `cache: 'pip'`.

The same single line in Python, on the job that fans setup-python out across every supported version.

Source: [django/django `.github/workflows/python_matrix.yml` lines 46-50](https://github.com/django/django/blob/b34b834378adc590a771cccd44b9fb0b953cdca2/.github/workflows/python_matrix.yml#L46-L50) at commit `b34b834`.

### immich-app/immich - .github/workflows/test.yml

```yaml
      - name: Setup Node
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version-file: '.nvmrc'
          cache: 'pnpm'
```

Key line: `cache: 'pnpm'`.

The pnpm store, restored by setup-node and keyed on the lockfile, on the test job of a self-hosted photo app.

Source: [immich-app/immich `.github/workflows/test.yml` lines 413-417](https://github.com/immich-app/immich/blob/19313e75fd1860f7da56fd23dc9f3dabfe9478b6/.github/workflows/test.yml#L413-L417) at commit `19313e7`.

<a id="shipped-by-starsling"></a>

## Shipped by StarSling

On Better Auth, StarSling agents closed two caching gaps: a turbo setting that silently disabled the local build cache so actions/cache had nothing to persist, and Playwright browsers reinstalling on every E2E run.

### Local build caching restored

better-auth/better-auth#7950: chore: fix turbo cache configuration in ci
Enabled local and remote Turborepo caching across three CI workflows.

StarSling found that setting TURBO_CACHE to remote:rw disabled Turborepo's local cache, so .turbo was never written and the actions/cache save step had nothing to store; removing the override restored the local-plus-remote default so the build cache persists across runs.

Diff: ci.yml, e2e.yml, and preview.yml drop the TURBO_CACHE: remote:rw env override, so Turborepo falls back to its local:rw,remote:rw default and actions/cache can persist the .turbo directory.

Source: [better-auth/better-auth#7950](https://github.com/better-auth/better-auth/pull/7950), merged 2026-02-13.
### Customer case study

[How Better Auth got 2x faster E2E tests and cut 20,000 CI minutes a month with StarSling](https://starsling.dev/customers/better-auth)

Per-job E2E from 2m 22s down to 1m 04s. Agent-native CI for the auth library every team's PR queue gates on.


### ~6,600 CI minutes/month

better-auth/better-auth#8073: chore(ci): optimize Playwright browser installs in E2E
Estimated time saved by caching Chromium and removing browser installs from six adapter jobs.

StarSling added a composite setup-playwright action so E2E jobs reuse the Playwright browser install work instead of repeating it on every run.

Diff: .github/actions/setup-playwright/action.yml caches Chromium and installs system dependencies in one reusable action.

Source: [better-auth/better-auth#8073](https://github.com/better-auth/better-auth/pull/8073), merged 2026-02-20.

<a id="github-actions-cache-keys-and-restore-keys"></a>

## How GitHub Actions cache keys and restore keys work

An exact `key` match is a cache hit. If the exact key misses, GitHub searches partial matches and then each `restore-keys` prefix in order within the cache scopes the run can access: the current branch and default branch, plus the pull request's base branch for a `pull_request` run. Put the runner OS, toolchain, and lockfile hash in the primary key so incompatible dependencies do not collide. Keep restore prefixes intentionally broad, and still run the install after a partial restore because that cache may not contain the current lockfile's complete dependency set. For an explicit `actions/cache` step, `cache-hit` is `true` only for an exact primary-key match, `false` for a partial restore, and empty when nothing was restored.

<a id="cache-dependency-path-lockfile"></a>

## Point the cache at the right lockfile: cache-dependency-path

`actions/setup-node` derives its cache key from the package manager lockfile it finds at the repository root by default. In a workspace or monorepo, that may not be the lockfile that changed, or there may be several. Set `cache-dependency-path` to the real repo-relative lockfile path, such as `pnpm-lock.yaml`, `packages/*/package-lock.json`, or a multi-line list, so the key tracks the dependencies each job installs. `actions/setup-python` has the same input for requirements, pipenv, and poetry files outside the root.

<a id="troubleshoot-github-actions-cache-misses"></a>

## Troubleshoot GitHub Actions cache misses

Start with the cache action's log and the evaluated key. Repeated misses usually mean the lockfile hash or another key segment changes every run, the configured `path` is not the package manager's real store, or branch and cache-version scope prevent a match. Cache version includes the cached path and compression tool, so identical text keys can still name incompatible entries. Confirm a warm run restores the expected path, check `cache-hit` when you use `actions/cache`, and remember that a new entry is saved only after a cache miss and a successful job.

<a id="why-it-matters"></a>

## Why it matters

Reinstalling dependencies from scratch wastes minutes on every job, and the waste multiplies across every matrix leg and shard. The one caveat to size honestly: caching only helps runs whose lockfile is unchanged (most PRs, re-runs, and dependabot bumps aside), so measure your real cache-hit rate rather than assuming every run benefits. Building Docker images in the same pipeline? Layer caching is the container-side analog, covered in [Docker builds in GitHub Actions](/ci/docker).

<a id="when-to-use"></a>

## When to use

**Use it when:** Any job that installs dependencies with a lockfile, which is almost every build and test job.

**Be careful when:** Skip a cache only when installs are already trivial (a handful of packages) or when the cache key would change on nearly every run, so the restore never hits.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent to audit and fix this practice in your own repo:

Inspect this repo's .github/workflows for dependency caching. For every job that installs packages (npm, pnpm, yarn, pip, cargo, go, gradle, maven), check whether a cache is configured: a `cache:` key on `actions/setup-node` / `setup-python` / `setup-go`, an explicit `actions/cache` step, or a language-specific cache action. Flag any install step (`pnpm install`, `pip install`, `cargo build`, etc.) that has no matching cache restoring its store, and confirm the cache key is derived from the lockfile so it actually hits. Add the right cache hook keyed on the lockfile, then show me the diff and open a PR rather than applying it blindly.

Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, https://github.com/actions/cache. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand:

- Search your workflows for a cache hook: `grep -rn 'cache' .github/workflows/`, look for `cache:` on a setup action, `actions/cache`, or a language-specific cache action.
- Confirm the install command is present but no matching cache exists (e.g. `pnpm install` / `pip install` / `cargo build` with nothing restoring its store).
- Read a job log: a warm run should show a cache-restore line and a much shorter install step than a cold run.

## Go further

- [Install the ci-score skill](https://starsling.dev/skills/ci-score) to grade your workflow configuration against all 11 CI Score checks and get a ranked fix for every gap. It grades configuration, not speed.

<a id="optimization-recipes"></a>

## Optimization recipes

Optimization recipes related to this practice, each with a copyable fix and its own stable rule id.

- [Optimize Turborepo caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-turborepo)
- [Use Docker layer caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/use-docker-layer-caching)
- [Optimize npm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-npm-install)
- [Optimize pnpm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-pnpm-install)
- [Stop reinstalling the same dependencies in GitHub Actions](https://starsling.dev/github-actions/optimizations/avoid-duplicate-dependency-installs)
- [Share setup steps across GitHub Actions jobs](https://starsling.dev/github-actions/optimizations/avoid-duplicated-setup)
- [Keep build caches between GitHub Actions runs](https://starsling.dev/github-actions/optimizations/use-incremental-builds)

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Set actions/checkout fetch-depth: 0 vs 1 vs 2 in GitHub Actions](https://starsling.dev/best-practices/github-actions/shallow-checkout)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [All GitHub Actions best practices](https://starsling.dev/best-practices/github-actions)

<a id="speedup-guides"></a>

## More ways to improve GitHub Actions

If you do not know why a run is slow yet, start with the diagnostic guide. [Let an agent find which of these applies: /ci-speedup](/skills/ci-speedup). If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement. To find exploitable workflow paths before attackers do, ci-secure checks the ten critical GitHub Actions attack vectors and lets you choose which fixes to apply.

- [GitHub Actions too slow](https://starsling.dev/github-actions-too-slow)
- [Fast GitHub Actions](https://starsling.dev/fast-github-actions)
- [GitHub Actions alternatives](https://starsling.dev/github-actions-alternatives)
- [GitHub Actions pricing](https://starsling.dev/github-actions-pricing)
- [Self-hosted GitHub Actions runners](https://starsling.dev/self-hosted-github-runners)
- [GitHub Actions CI/CD pipeline](https://starsling.dev/github-actions-ci-cd)
- [Docker CI on GitHub Actions](https://starsling.dev/ci/docker)
- [Install the free /ci-score skill to improve your GitHub Actions setup](https://starsling.dev/skills/ci-score)
- [Install the /ci-secure skill to close critical attack vectors in GitHub Actions](https://starsling.dev/skills/ci-secure)
- [Browse GitHub Actions agent skills](https://starsling.dev/skills)

<a id="faq"></a>

## FAQ

### Does `actions/cache` or the setup action's `cache:` option work better?

For the common ecosystems, the built-in `cache:` on `actions/setup-node`, `setup-python`, and `setup-go` is simplest, it picks a sensible key from your lockfile automatically. Reach for an explicit `actions/cache` step when you need to cache something the setup action doesn't cover, like a build cache or a browser-binary download.

### Why is my cache not speeding anything up?

Usually the cache key changes too often (so every run is a miss), or the cached path isn't where the tool actually reads from. Check the restore/miss lines in the job log and confirm the key is derived from the lockfile, not from something that changes each run.

### How do I cache pnpm in a monorepo or workspace?

Point `actions/setup-node` at the lockfiles with `cache-dependency-path: '**/pnpm-lock.yaml'` (a multi-line list of explicit paths works too). On its own, `cache:` only looks for that package manager's lockfile in the working-directory root, so in a workspace whose lockfiles live in sub-projects the step does not quietly mis-key the cache: it fails the job outright with `Dependencies lock file is not found`. `cache-dependency-path` is what points it at the lockfiles you actually have, and makes the key cover every one of them.

### How do I clear or invalidate a GitHub Actions cache?

A cache entry is immutable. Change the primary key when you want the next successful run to create a fresh entry, or delete the existing entry under Actions > Caches (or with `gh cache delete`) when you need to reclaim storage or remove it immediately. If broad `restore-keys` can still match the old entry, change those prefixes too or omit them for that invalidation run.

<a id="sources"></a>

## Sources

1. [GitHub Docs - caching dependencies to speed up workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
2. [GitHub Docs - managing caches](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manage-caches)
3. [actions/setup-node - built-in dependency caching](https://github.com/actions/setup-node)
4. [actions/cache](https://github.com/actions/cache)
