How to check if package lock is in sync

Run npm ci --dry-run. It exits 0 when package.json and package-lock.json agree, and exits 1 with code EUSAGE and a line naming the package that is missing or invalid when they do not. The second signal is git diff --exit-code package-lock.json after an npm install.

Why check this

This is the first step of any CI job that installs dependencies, and the check a reviewer runs on a pull request that edits package.json. A lockfile that lags behind package.json means the build agent resolves versions at install time instead of reading them, so two runs of the same commit can install two different trees.

The failure it catches is a merge that adds a dependency to package.json and forgets the lockfile. Local work continues, because npm install repairs the lockfile in place and says nothing about it. The CI job that runs npm ci stops the release instead.

Prerequisites

{
  "name": "lock-demo",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "ms": "2.1.3"
  }
}

Steps

  1. Step 1.

    Establish the baseline on the untouched project.

    npm ci --dry-run; echo "exit=$?"
    
    up to date in 688ms
    exit=0

    --dry-run resolves and compares without deleting node_modules, so this is safe on a working tree. Exit 0 is the whole answer; the wording above it varies with what is already installed.

  2. Step 2.

    Add a dependency to package.json only, leaving the lockfile untouched, then run the same command.

    npm ci --dry-run; echo "exit=$?"
    
    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: dotenv@17.4.2 from lock file
    npm error
    npm error Clean install a project
    …
    exit=1

    The line that carries the finding is Missing: dotenv@17.4.2 from lock file. It names the package and the version npm would have resolved. Everything from Clean install a project on is the usage text.

  3. Step 3.

    Run npm install on the same drifted project and read every line it prints.

    npm install; echo "exit=$?"
    
    added 1 package, and audited 3 packages in 1s
    
    1 package is looking for funding
    run `npm fund` for details
    
    found 0 vulnerabilities
    exit=0

    It rewrote package-lock.json and no line says so. The command reports an install, a funding note and a clean audit, then exits 0. This is why drift survives local development and appears only in CI.

  4. Step 4.

    Ask git what that install changed. On a pull request this is the question: did the author commit the rewrite?

    git diff --exit-code --stat -- package-lock.json; echo "exit=$?"
    
    warning: in the working copy of 'package-lock.json', LF will be replaced by CRLF the next time Git touches it
    package-lock.json | 13 +++++++++++++
    1 file changed, 13 insertions(+)
    exit=1

    The warning line is a Windows line-ending notice from git and not part of the finding. Exit 1 means the working tree lockfile differs from the committed one, so the branch is not reproducible until those 13 lines are committed.

  5. Step 5.

    Widen a range instead of adding a package, and run the sync check again.

    npm ci --dry-run; echo "exit=$?"
    
    up to date in 410ms
    
    1 package is looking for funding
    run `npm fund` for details
    exit=0

    package.json now declares ms as ^2.0.0 while the lockfile still records that dependency as 2.1.3. The two strings differ and the pair is reported as in sync, because 2.1.3 satisfies the range.

  6. Step 6.

    Narrow the same range past the locked version, to see the other failure wording.

    npm ci --dry-run
    
    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 Invalid: lock file's ms@2.1.3 does not satisfy ms@2.0.0
    npm error
    npm error Clean install a project
    …

    Invalid rather than Missing, with both versions named. A pin edited down to 2.0.0 while the lockfile holds 2.1.3 produces this, and so does a range that was repointed at a different major.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Exit 0 from npm ci --dry-run | Every range in package.json is satisfied by the lockfile | Nothing. The two files may still differ textually, which is allowed. | | Missing: <pkg>@<version> from lock file | package.json declares a dependency the lockfile never resolved | Run npm install and commit the lockfile in the same change. | | Invalid: lock file's ms@2.1.3 does not satisfy ms@2.0.0 | The range was narrowed or repointed past the locked version | Same fix, and confirm the narrowing was deliberate. | | npm error code EUSAGE with no Missing or Invalid line | The lockfile is absent or an unsupported version | Read the lockfileVersion field. An npm 6 lockfile needs an npm install under npm 7 or later. | | Exit 1 from git diff --exit-code | An install rewrote the lockfile and it was not committed | Commit it. A lockfile regenerated on every agent is not a lockfile. |

Common mistakes

Sign: package.json and package-lock.json plainly disagree in a diff, and npm ci passes anyway.Cause: The check is satisfiability, not equality. After ms was widened from 2.1.3 to ^2.0.0, the lockfile still recorded that dependency as 2.1.3 and npm ci --dry-run exited 0. A text comparison of the two files is a different check, and it reports drift that npm does not act on.
Sign: A reviewer runs npm install to reproduce a failing branch and the failure disappears.Cause: npm install repairs the drift in place. In step 3 it printed an install line, a funding note and found 0 vulnerabilities, then exited 0, with nothing about the lockfile it had just rewritten. When the question is whether the committed state installs, run npm ci --dry-run instead.
Sign: The CI job passes on both branches and fails on main after the merge.Cause: Each branch added a dependency and regenerated the lockfile. git merges the two package.json files cleanly and produces a lockfile that satisfies neither branch. Run the dry run on the merge result, not only on each branch.
Sign: npm ci is used as the check on a developer machine and the working tree stops running tests.Cause: Without --dry-run, npm ci deletes node_modules before it compares, so a failed sync check leaves a project with no dependencies installed. Add --dry-run locally. In CI the deletion is the reason to use the command.

What to check next

FAQ

What is the difference between npm install and npm ci?

npm install resolves ranges and may write a new lockfile. npm ci deletes node_modules, installs exactly what the lockfile records, and refuses to start when package.json declares something the lockfile does not satisfy. One converges on a tree, the other reproduces one.

Can I check sync without touching node_modules?

Yes. npm ci --dry-run performs the comparison and stops before any file is removed or written. Steps 1, 2, 5 and 6 here were captured that way, on a project whose node_modules stayed in place throughout.

Does a lockfile diff always mean a problem?

No. npm rewrites resolved URLs and integrity metadata when the registry or the npm version changes, and that produces a diff with no dependency change in it. The signal is the exit code of npm ci --dry-run, not the size of the diff.

Which npm versions produce which lockfile?

npm 7 and later write lockfileVersion 2 or 3. The demo project here produced version 3 under npm 10.9.8. A lockfile written by npm 6 does not satisfy npm ci under npm 7 or later until an install regenerates it.

Why does npm name a version that is not in my package.json?

Missing: dotenv@17.4.2 is the version npm resolved from the range while comparing. It is a report of what the install would have picked, not something the project declared.

Verified

Verified by Maks Vernynpm 10.9.8Node.js 22.23.2git 2.41.0.windows.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate7 minpublished updated Maks Verny