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.
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.
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 installin CI is the classic mistake. It treats the lockfile as a suggestion and will rewritepackage-lock.jsonto satisfy an out-of-range manifest, so a pipeline can install a version no reviewer ever saw. Always usenpm cifor unattended installs; reservenpm installfor interactively adding a dependency. -
pnpm’s CI default hides a footgun locally. pnpm turns
--frozen-lockfileon automatically only when it detects a CI environment. Run the samepnpm installon a laptop and it will happily update the lockfile, so a “works in CI, fails on my machine” gap opens up. Pass--frozen-lockfileexplicitly everywhere the install must be strict. -
--immutableis 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
postinstallfrom executing. Combine strict installs with--ignore-scriptsor a reviewed allow-list when running untrusted dependency trees — Disabling npm Install Scripts covers the flag, the persistent.npmrcsetting, 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.
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.
Related
Permalink to "Related"- Dependency Pinning Best Practices — the parent guide to keeping resolved versions authoritative across environments
- Pinning Transitive Dependencies in Monorepos — extending strict lockfile enforcement to nested workspace dependencies
- Parsing package-lock.json for Dependency Audits — reading the lockfile these commands enforce, to audit resolved versions and integrity hashes
- Enforcing Package Manager Versions with Corepack — pinning the manager itself, so the flag you chose is the flag CI actually runs