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.
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 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.
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.
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