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.
The drift almost always comes from one of these:
- 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. - 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.
- A new workspace package was added to
workspacesbut the lockfile was never regenerated, soMissing: @acme/ui@1.8.0refers to a local package. - Another package manager touched the manifest. Running
pnpm addoryarn addin an npm repository updatespackage.jsonand a different lockfile. - A merge conflict resolved textually. Choosing "theirs" in
package-lock.jsonand "ours" inpackage.jsonleaves 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.
- Use the same npm version as CI. Pin it in
package.jsonso developers and runners agree:
{
"packageManager": "npm@10.9.2",
"engines": { "npm": ">=10.9" }
}
- 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.
-
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. -
For workspace packages, run the install from the repository root. Running
npm installinsidepackages/uiin an npm workspace still updates the root lockfile, but it is easy to confuse with a stray nestedpackage-lock.json; delete any nested lockfiles, since npm workspaces use one lockfile at the root. -
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.
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 cias a required check on every pull request, so drift is caught before merge rather than onmain. - Pin npm with
packageManagerand 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-onlywhenpackage.jsonis 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 covers lockfile policy across npm, pnpm and Yarn.
- Resolving package-lock.json Merge Conflicts fixes the most common way the two files drift.
- Fixing pnpm ERR_PNPM_OUTDATED_LOCKFILE in CI is the pnpm equivalent of this error.
- Pinning the Package Manager with Corepack keeps lockfiles generated by one npm version.