Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Fixing 'npm ci' Lockfile Out of Sync Errors

npm ci is the install command built for CI: it deletes node_modules, installs exactly what package-lock.json records, and refuses to run if the lockfile and package.json disagree. That refusal is the most common reason a pipeline fails immediately after a pull request that "only changed a version number". This guide explains what npm compares, the half-dozen ways the two files drift apart, and how to repair and prevent the drift without resorting to npm install in CI.

Exact symptoms and error messages

The failure happens before any package is downloaded:

npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: zod@3.24.1 from lock file
npm error Invalid: lock file's typescript@5.4.5 does not satisfy typescript@^5.6.0
npm error Missing: @acme/ui@1.8.0 from lock file

The trailing lines are the important part. Missing: means a dependency declared in some package.json has no entry in the lockfile. Invalid: means the lockfile has an entry, but its version does not satisfy the declared range. In a workspace, the message covers every package's manifest, not only the root.

A related failure appears when there is no lockfile at all:

npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1.

Root cause analysis

Before installing, npm ci loads the dependency tree described by the lockfile and checks that every dependency declared in every workspace manifest is satisfied by that tree. It never edits the lockfile to fix a mismatch — that is the point of the command. A lockfile that does not satisfy the manifests would have to be re-resolved, and re-resolving in CI means CI installs something nobody reviewed. The reasoning behind strict CI installs is covered in Lockfile Management Strategies.

What npm ci checks before installing npm ci reads every workspace manifest and the lockfile, checks each declared range against the locked version, and either installs verbatim or stops with EUSAGE. read manifests root and every workspace package.json read lockfile package-lock.json tree compare ranges is each declared range satisfied? install or EUSAGE exact tree, or refuse with Missing/Invalid
npm ci verifies, then installs verbatim — it never rewrites the lockfile to resolve a mismatch.

The drift almost always comes from one of these:

  1. A manifest edited by hand or by a bot without running an install — a version bump in package.json, a Renovate or Dependabot branch that updated the manifest but failed to regenerate the lockfile, or a merge that took one side of each file.
  2. A different npm version generated the lockfile. Newer npm releases occasionally change how peer or optional dependencies are recorded. A lockfile produced by one major and validated by another can disagree on what counts as satisfied.
  3. A new workspace package was added to workspaces but the lockfile was never regenerated, so Missing: @acme/ui@1.8.0 refers to a local package.
  4. Another package manager touched the manifest. Running pnpm add or yarn add in an npm repository updates package.json and a different lockfile.
  5. A merge conflict resolved textually. Choosing "theirs" in package-lock.json and "ours" in package.json leaves both files valid but inconsistent.

Resolution and configuration patch

Fix the lockfile on a developer machine or in a dedicated bot job, then commit it. Never "fix" CI by switching it to npm install.

Repairing an out-of-sync lockfile Check out the branch, align the npm version, run npm install to update the lockfile minimally, review the diff, verify with npm ci and commit. align npm version same major as CI (packageManager field) avoids unrelated lockfile churn npm install re-resolves only the mismatched ranges review git diff only expected packages should change large diffs mean a version mismatch npm ci proves the files now agree commit lockfile with the manifest change
npm install updates only what the manifests require; verify with npm ci before committing.
  1. Use the same npm version as CI. Pin it in package.json so developers and runners agree:
{
  "packageManager": "npm@10.9.2",
  "engines": { "npm": ">=10.9" }
}
  1. Regenerate minimally. On the branch that fails:
git checkout feature/upgrade-zod
npm install            # updates package-lock.json only where manifests require it
git diff --stat package-lock.json
npm ci                 # must now succeed
git add package-lock.json && git commit -m "Sync lockfile with manifest changes"

npm install without arguments keeps every existing locked version that still satisfies its range, so the diff should touch only the packages named in the error plus their new transitive dependencies.

  1. If the diff is enormous, stop and check the npm version. A diff that rewrites thousands of lines usually means the lockfile was regenerated by a different major version, which also changes lockfileVersion. Revert, switch versions, and repeat.

  2. For workspace packages, run the install from the repository root. Running npm install inside packages/ui in an npm workspace still updates the root lockfile, but it is easy to confuse with a stray nested package-lock.json; delete any nested lockfiles, since npm workspaces use one lockfile at the root.

  3. For a lockfile that was merged incorrectly, regenerate from the target branch's lockfile rather than attempting a line-by-line merge — see Resolving package-lock.json Merge Conflicts.

Keeping bots honest

Dependency bots are the largest source of out-of-sync pull requests in many repositories. Renovate regenerates lockfiles by default; check that postUpdateOptions and the configured npm version match your CI. Dependabot updates both files for npm, but can fail silently on workspaces with private registries it cannot authenticate against — the pull request then contains only the manifest change. Give the bot registry credentials, and add a required status check that runs npm ci, so an incomplete bot pull request can never merge. Bot configuration is covered in Configuring Renovate for Grouped Updates in a Monorepo.

Why not just use npm install in CI?

It is tempting to replace npm ci with npm install to make the error go away. That trades a visible failure for invisible drift. npm install re-resolves any unsatisfied range against the registry at build time, so the tree you test is not the tree anyone reviewed, and two pipeline runs an hour apart can install different versions. It also rewrites the lockfile inside the runner, where the change is discarded. npm ci failing is the lockfile doing its job: it tells you, before anything is built, that the reviewed dependency set is incomplete.

npm ci versus npm install in a pipeline Compares the two install commands on lockfile handling, reproducibility, speed and failure behaviour when files disagree. npm ci npm install Lockfile changes never rewrites when needed Tree matches review exactly only if already in sync Existing node_modules deleted first reused and patched Manifest drift fails with Missing/Invalid silently re-resolves Typical CI speed fast with cached ~/.npm slower, more network
npm ci fails fast on drift; npm install hides it by resolving new versions inside the runner.

Lockfile versions and npm upgrades

The lockfileVersion field at the top of package-lock.json records the format. npm 6 wrote version 1, npm 7 and 8 wrote version 2 (a superset readable by npm 6), and npm 9 and later write version 3, which drops the legacy dependencies section and keeps only the packages map. A version 3 lockfile is roughly half the size of a version 2 file, and far easier to review.

Upgrading npm across one of those boundaries is a deliberate change, not something to let happen as a side effect of a feature branch. Do it in its own pull request: bump the packageManager field, run npm install once to convert the lockfile, confirm npm ci passes, and merge before other work lands. Mixing a format conversion with a dependency upgrade produces a diff nobody can review and makes every open branch conflict with main.

Within a single major, npm occasionally changes how it records peer and optional dependencies, which is why a teammate on a slightly different minor can produce a lockfile that npm ci on CI rejects as Invalid. Pinning the exact version with packageManager — and having CI use it through Corepack or the setup-node action's package-manager support — removes that class of failure entirely.

Worked example: a Dependabot pull request that never passes

A team sees every Dependabot pull request for @acme/* packages fail at npm ci with Invalid: lock file's @acme/ui@1.7.2 does not satisfy @acme/ui@^1.8.0. Opening the pull request shows a one-line change to apps/web/package.json and no lockfile change at all. Dependabot could not reach the private registry that hosts @acme packages, so it updated the manifest and skipped the lockfile. The team adds the registry to Dependabot's registries configuration with a read-only token, and the next pull request contains both files. They also make the npm ci check required, so a bot failure can no longer look like a flaky pipeline.

CLI validation and debug commands

# Reproduce the CI check locally without touching node_modules
npm ci --dry-run

# Which versions does the lockfile hold for the packages in the error?
npm ls zod typescript --workspaces --include-workspace-root

# Show which lockfile format and npm produced it
node -p "require('./package-lock.json').lockfileVersion"
npm --version

# Find nested lockfiles that should not exist in a workspace
find . -name package-lock.json -not -path "./node_modules/*" -not -path "./package-lock.json"

Prevention and CI/CD guardrails

  • Run npm ci as a required check on every pull request, so drift is caught before merge rather than on main.
  • Pin npm with packageManager and enable Corepack or a setup action that reads it, so every lockfile is generated by the same version.
  • Commit manifest and lockfile changes together. A pre-commit hook that runs npm install --package-lock-only when package.json is staged keeps them aligned.
  • Give bots registry credentials and verify their pull requests include lockfile changes.

Frequently Asked Questions

Why does npm ci pass locally but fail in CI? Usually because a different npm version runs in CI and interprets the lockfile differently, or because your local run used a lockfile you have not committed yet. Compare npm --version and run git status before pushing.

Can I make npm ci ignore peer dependency mismatches? npm ci --legacy-peer-deps changes how peers are resolved and must match how the lockfile was generated. If the lockfile was produced with that flag, set it in the project .npmrc so every install uses it; otherwise fix the peer conflict itself.

Does npm ci work with npm-shrinkwrap.json? Yes. If npm-shrinkwrap.json exists, npm ci uses it instead of package-lock.json. Shrinkwrap is intended for published CLIs and applications that want to lock their dependencies for consumers, not for libraries.

Related

Lockfile Management Strategies