Skip to content
Node.js nd modules 4 min read

package-lock.json & Reproducible Installs

A package.json declares what your project depends on, but it usually does so with flexible version ranges like ^4.18.0. That flexibility means two installs days apart can resolve to different versions, and a deeply nested transitive dependency you never named can change underneath you. The package-lock.json file closes that gap: it records the exact resolved version, integrity hash, and location of every package in your dependency tree, turning installs from “approximately reproducible” into bit-for-bit deterministic.

What package-lock.json actually stores

When you run npm install, npm resolves your dependency ranges into a concrete tree and writes the result to package-lock.json. The file pins three things that package.json cannot: the exact version of every direct and transitive dependency, a cryptographic integrity hash (Subresource Integrity) for each tarball, and the resolved registry URL it was fetched from.

A single entry in the packages map looks like this:

{
  "node_modules/express": {
    "version": "4.19.2",
    "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
    "integrity": "sha512-faAy0pQrh8Xml3DJtfXs/wD6mn8NM9Y2tFcEv5+5pYn7uIqDk4dFsFt7ZTYduBGyAILGW48fz1uM9... ",
    "dependencies": {
      "accepts": "~1.3.8",
      "body-parser": "1.20.2"
    }
  }
}

Because the integrity hash is verified at install time, a tampered or corrupted tarball is rejected before it ever lands in node_modules. That makes the lockfile a security boundary, not just a convenience.

npm install vs npm ci

The two commands look similar but behave very differently, and choosing the right one is the heart of reproducible installs.

npm install is the authoring command. It reads package.json, resolves ranges, and may update package-lock.json — adding new packages, bumping versions to satisfy changed ranges, or healing an out-of-date tree. It is forgiving: if the lockfile and package.json disagree, it reconciles them.

npm ci (“clean install”) is the reproducing command, built for CI and production builds. It installs strictly from the lockfile and never writes to it. Before installing it deletes node_modules entirely, then recreates it exactly as the lockfile describes. Crucially, if package.json and package-lock.json are out of sync, npm ci fails loudly instead of silently rewriting the lockfile.

# Local development — may modify the lockfile
npm install

# CI / production — exact, fast, fails on drift
npm ci

If you run npm ci against a project whose lockfile is stale, you get a hard error:

Output:

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.
npm error
npm error Missing: [email protected] from lock file
Aspectnpm installnpm ci
Readspackage.json + lockfilelockfile (validates against package.json)
Writes lockfileYes, may update itNever
Requires existing lockfileNoYes
Deletes node_modules firstNoYes
On lockfile/package.json mismatchReconciles silentlyExits with an error
Intended forLocal developmentCI, Docker builds, releases
SpeedSlowerFaster (skips range resolution)

Use npm ci everywhere a build must be reproducible — CI pipelines, Docker images, and deploy steps. Reserve npm install for when you are deliberately changing dependencies.

Lockfile version

The top-level lockfileVersion field tells npm how to interpret the file’s structure. It has evolved across npm releases:

lockfileVersionnpm versionNotes
1npm 5–6Legacy nested dependencies tree only
2npm 7–8Adds the flat packages map; backward compatible with v1 clients
3npm 9+ (Node 18/20/22)packages-only, drops the legacy tree — smaller and the current default
{
  "name": "my-app",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": { "...": {} }
}

Lockfile version 2 was intentionally dual-format so that older npm 6 clients could still read it. Version 3 (the default on modern Node LTS) drops the legacy section for a leaner file. You rarely set this by hand — npm writes it based on the version doing the install.

Why the lockfile must be committed

Commit package-lock.json to version control. It is not a build artifact; it is the contract that guarantees every developer, CI runner, and production server installs the identical dependency tree.

If you .gitignore it, you lose every benefit: teammates resolve ranges independently and get drifting versions, “works on my machine” bugs reappear, npm ci has nothing to install from, and the integrity hashes that protect against supply-chain tampering vanish. A committed lockfile also makes dependency changes reviewable — a pull request diff shows exactly which versions moved and why.

# Verify the lockfile is tracked, never ignored
git status package-lock.json
git log --oneline -- package-lock.json

Gotcha: only commit package-lock.json if npm is your package manager. Yarn uses yarn.lock and pnpm uses pnpm-lock.yaml. Committing two different lockfiles invites them to disagree — pick one tool per repo.

Best Practices

  • Always commit package-lock.json and never add it to .gitignore.
  • Use npm ci in CI, Docker, and production; use npm install only when intentionally changing dependencies.
  • Treat lockfile changes as reviewable — inspect version bumps in pull request diffs.
  • Pin a single package manager per repository so you keep exactly one lockfile.
  • Run npm audit and npm outdated periodically, then update deliberately with npm install and commit the new lockfile.
  • Let npm manage lockfileVersion; ensure your team and CI run compatible Node/npm LTS versions to avoid format churn.
  • Never hand-edit the lockfile — regenerate it by deleting it (and node_modules) and running npm install if it becomes corrupted.
Last updated June 14, 2026
Was this helpful?