npm ci vs pnpm --frozen-lockfile vs yarn --immutable

Permalink to "npm ci vs pnpm --frozen-lockfile vs yarn --immutable"

Part of Dependency Pinning Best Practices, this page compares the three package-manager commands that enforce a frozen lockfile in CI so an install can never silently resolve a version you never reviewed.

Quick Reference

Permalink to "Quick Reference"
Property npm ci pnpm install --frozen-lockfile yarn install --immutable
Lockfile read package-lock.json / npm-shrinkwrap.json pnpm-lock.yaml yarn.lock
Rewrites lockfile Never Never Never
Fails on lockfile drift Yes (exits non-zero) Yes (exits non-zero) Yes (exits non-zero)
Deletes node_modules first Always No (uses content-addressed store) No (uses PnP or cache)
Requires a lockfile to exist Yes (errors if absent) Yes Yes
CI default without flag n/a — always strict --frozen-lockfile on when CI detected --immutable on when CI detected (Berry)
Ecosystem npm pnpm Yarn Berry (v2+)

Reach for the strict command whenever the install runs unattended — CI, release builds, container images. On a developer machine you want the mutating install so the lockfile updates as you add dependencies.

What “frozen” actually means

Permalink to "What “frozen” actually means"

A package manifest (package.json) records version ranges such as ^4.17.0; a lockfile records the exact resolved version and its integrity hash. The ordinary install command treats the lockfile as advisory — if the manifest allows a newer version, it will resolve, install, and rewrite the lockfile to match, all without failing. That is convenient locally and dangerous in automation, because a build can quietly pull a version that no one reviewed. The strict variants invert the priority: the lockfile becomes authoritative, the manifest is checked against it, and any disagreement aborts the install with a non-zero exit code. That single behavior is what makes a build reproducible and what turns a tampered or drifted lockfile into a hard failure instead of a silent substitution. For the deeper story on how transitive versions get pinned, see Pinning Transitive Dependencies in Monorepos.

Frozen lockfile decision flow A lockfile and manifest feed an install step. A mutating install rewrites the lockfile and continues; a frozen install compares them and blocks the build when they diverge. lockfile + manifest install step in range? mutating rewrite lockfile, continue frozen drift → exit non-zero build blocked reviewed

Canonical CI examples

Permalink to "Canonical CI examples"

Each of the following installs strictly from the committed lockfile and fails the job if the lockfile does not satisfy the manifest.

npm

Permalink to "npm"
# .github/workflows/ci.yml
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'
- name: Install (strict)
  run: npm ci

npm ci deletes node_modules up front, installs exactly what package-lock.json records, and errors if the lockfile is missing or out of sync with package.json.

pnpm

Permalink to "pnpm"
- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'
- name: Install (strict)
  run: pnpm install --frozen-lockfile

The flag makes pnpm-lock.yaml authoritative; pnpm resolves from its content-addressed store rather than wiping node_modules, so a warm cache stays fast.

yarn (Berry, v2+)

Permalink to "yarn (Berry, v2+)"
- uses: actions/setup-node@v4
  with:
    node-version: '20'
- name: Enable Corepack
  run: corepack enable
- name: Install (strict)
  run: yarn install --immutable

--immutable refuses to install if yarn.lock would change and prints a diff of the offending entries.

Variants

Permalink to "Variants"

Yarn Classic (v1.x)

Permalink to "Yarn Classic (v1.x)"

Yarn 1 predates the --immutable rename. Use the older flag:

# Yarn 1.x only — --immutable does not exist here
yarn install --frozen-lockfile

Fail hard on any lockfile write

Permalink to "Fail hard on any lockfile write"

Yarn Berry can additionally reject an install that would only touch the cache metadata, which is useful for release builds:

yarn install --immutable --immutable-cache

Deterministic container image layer

Permalink to "Deterministic container image layer"

Copy only the manifest and lockfile before installing so Docker caches the dependency layer independently of source changes:

COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • npm install in CI is the classic mistake. It treats the lockfile as a suggestion and will rewrite package-lock.json to satisfy an out-of-range manifest, so a pipeline can install a version no reviewer ever saw. Always use npm ci for unattended installs; reserve npm install for interactively adding a dependency.

  • pnpm’s CI default hides a footgun locally. pnpm turns --frozen-lockfile on automatically only when it detects a CI environment. Run the same pnpm install on a laptop and it will happily update the lockfile, so a “works in CI, fails on my machine” gap opens up. Pass --frozen-lockfile explicitly everywhere the install must be strict.

  • --immutable is Berry-only. On Yarn 1.x the flag is unrecognized and the job fails with a confusing usage error, not a lockfile error. Detect the version (yarn --version) or pin it with Corepack before choosing the flag.

  • A missing lockfile is a different failure. All three strict commands error when there is no lockfile at all, rather than generating one. If your repository has never committed a lockfile, the first strict run will fail — commit the lockfile from a local mutating install first.

  • Post-install scripts still run. Freezing the lockfile pins which versions install; it does not stop a pinned package’s postinstall from executing. Combine strict installs with --ignore-scripts or a reviewed allow-list when running untrusted dependency trees.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the command fails on drift

Permalink to "1. Confirm the command fails on drift"

Intentionally desynchronize the manifest and lockfile, then run the strict command:

# Bump a range in package.json without updating the lockfile, then:
npm ci

Expected output — a non-zero exit and a message of this shape:

npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync. Please update your lock file with
npm error `npm install` before continuing.

2. Confirm reproducibility across two clean runs

Permalink to "2. Confirm reproducibility across two clean runs"
rm -rf node_modules && npm ci && sha256sum package-lock.json
rm -rf node_modules && npm ci && sha256sum package-lock.json

The two package-lock.json digests must be identical — a strict install never mutates the lockfile.

3. CI gate — refuse an uncommitted lockfile change

Permalink to "3. CI gate — refuse an uncommitted lockfile change"

Add a step that fails if the install altered the lockfile (it never should):

- name: Assert lockfile unchanged
  run: git diff --exit-code package-lock.json

A non-zero exit here means something ran a mutating install and the pipeline should block the merge.

Permalink to "Related"

Related Articles

Pinning Transitive Dependencies in Monorepos
Dependency Pinning Best Practices Supply Chain Auditing & Depend…