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

Each lockfile entry carries more than a version number. npm records resolved and integrity per package, pnpm stores a tarball digest against every resolution in pnpm-lock.yaml, and Yarn Berry keeps a checksum field per entry. A strict install verifies those digests as it unpacks, so a registry that serves different bytes under a version number you already have recorded fails the install instead of quietly poisoning node_modules. That is also why an unexplained lockfile diff deserves review on its own terms — the version column may be unchanged while a digest moved. Detecting Lockfile Tampering in Pull Requests covers the review mechanics for exactly that case.

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.

In all three pipelines, cache the package manager’s store rather than node_modules, keyed on a hash of the lockfile. actions/setup-node does this for you when you set cache: 'npm' or cache: 'pnpm', and the resulting key changes only when the lockfile changes — exactly the granularity a strict install wants. Caching node_modules directly defeats the purpose: npm ci deletes the directory before the restored copy can be used, and a tree restored from a different branch can mask a drift the install was supposed to catch.

Variants

Permalink to "Variants"

Which strict command you reach for is decided by two facts about the repository: the package manager it uses, and, for Yarn, the major version in play. The flag names diverged when Yarn Berry renamed --frozen-lockfile to --immutable, so a workflow copied from one repository into another frequently carries a flag the local Yarn does not recognize. Resolve both questions before writing the install step.

Choosing the strict install flag A decision tree branching from the package manager and major version into four leaves: npm ci, pnpm install with frozen-lockfile, yarn install with immutable for Berry, and yarn install with frozen-lockfile for Yarn Classic. Which package manager and which major version? npm pnpm yarn Berry (v2+) yarn Classic (1.x) npm ci pnpm install --frozen-lockfile yarn install --immutable yarn install --frozen-lockfile

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 — Disabling npm Install Scripts covers the flag, the persistent .npmrc setting, and how to handle the handful of packages that genuinely need a build step.

Those five conditions are worth reading as a grid, because only the first two are enforcement failures. The rest are behaviours the strict flag never claimed to cover, and each needs its own control.

What each strict install command blocks A five-row matrix comparing npm ci, pnpm install with frozen-lockfile and yarn install with immutable across manifest drift, a missing lockfile, cache additions, a stale node_modules directory and lifecycle scripts. condition at install time npm ci pnpm install --frozen-lockfile yarn install --immutable manifest range drifted blocked blocked blocked lockfile absent errors errors errors store or cache additions allowed allowed allowed unless --immutable-cache stale node_modules present deleted first reconciled reconciled postinstall script in tree still runs still runs still runs Only the top two rows are enforcement; the rest need separate controls.

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.

4. Learn the pnpm and yarn drift signatures

Permalink to "4. Learn the pnpm and yarn drift signatures"

The three commands fail with distinct messages, and recognizing them saves a debugging round trip. Desynchronize the manifest again and run:

pnpm install --frozen-lockfile
ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with package.json
yarn install --immutable
YN0028: The lockfile would have been modified by this install, which is
explicitly forbidden.

Both exit non-zero. If instead you see a usage error naming an unknown option, you are on Yarn 1.x and need --frozen-lockfile; if pnpm succeeds and rewrites the lockfile, the flag was dropped somewhere in the job definition.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Is npm install safe to use in CI?

No. npm install can silently rewrite package-lock.json to satisfy an out-of-range manifest, so it may install versions that were never reviewed. Use npm ci in CI — it installs strictly from the lockfile and errors when the lockfile and package.json disagree.

Does pnpm use a frozen lockfile automatically in CI?

Yes. When pnpm detects a CI environment it defaults --frozen-lockfile to true, so pnpm install behaves strictly. Pass it explicitly anyway to keep behavior identical on developer machines and in the pipeline.

What is the yarn classic equivalent of yarn --immutable?

yarn install --frozen-lockfile. The --immutable flag is a Yarn Berry (v2+) rename; on Yarn 1.x it does not exist. Check the yarn version in CI before choosing the flag.

Why does npm ci delete node_modules before installing?

Because a partially populated node_modules is the main source of non-reproducible installs. npm ci removes the directory outright and rebuilds the tree from package-lock.json, so a leftover folder from an earlier branch, a stray npm link, or a half-finished install cannot influence the result. It also makes install time predictable, because the work no longer depends on what happened to be on disk.

Can a frozen lockfile stop a malicious package version?

Only partly. It fixes the versions already recorded, which blocks an attacker who widens a range or slips a newer release into the build. It does nothing about a version that was already malicious when the lockfile was written, and nothing about a registry serving different bytes under the same version number — the integrity digest recorded beside each entry catches that second case, not the flag.

Permalink to "Related"

Related Articles

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