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
| Aspect | npm install | npm ci |
|---|---|---|
| Reads | package.json + lockfile | lockfile (validates against package.json) |
| Writes lockfile | Yes, may update it | Never |
| Requires existing lockfile | No | Yes |
Deletes node_modules first | No | Yes |
On lockfile/package.json mismatch | Reconciles silently | Exits with an error |
| Intended for | Local development | CI, Docker builds, releases |
| Speed | Slower | Faster (skips range resolution) |
Use
npm cieverywhere a build must be reproducible — CI pipelines, Docker images, and deploy steps. Reservenpm installfor 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:
lockfileVersion | npm version | Notes |
|---|---|---|
1 | npm 5–6 | Legacy nested dependencies tree only |
2 | npm 7–8 | Adds the flat packages map; backward compatible with v1 clients |
3 | npm 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.jsonif npm is your package manager. Yarn usesyarn.lockand pnpm usespnpm-lock.yaml. Committing two different lockfiles invites them to disagree — pick one tool per repo.
Best Practices
- Always commit
package-lock.jsonand never add it to.gitignore. - Use
npm ciin CI, Docker, and production; usenpm installonly 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 auditandnpm outdatedperiodically, then update deliberately withnpm installand 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 runningnpm installif it becomes corrupted.