npm install vs npm ci: Choosing the Right Command for Reproducible Builds
npm install resolves and can change your dependency tree; npm ci reproduces the lockfile exactly. A decision guide with a behavior comparison, CI implementation, and validation checks.
24 Aug 2025, 21:12 UTC

Two developers run the same command on the same repository and get different dependency trees. A CI build passes on Monday and fails on Wednesday with no code changes. Both symptoms usually trace back to one decision: using npm install where npm ci belonged. The takeaway is simple — npm install resolves and may change versions; npm ci reproduces exactly what the lockfile pins. Picking correctly per environment eliminates an entire class of "works on my machine" bugs.
The decision and its constraints
The choice hinges on one question: are you allowed to change the dependency tree, or must you reproduce it?
Local development involves intentional change — adding a package, upgrading a library, pruning something unused. That is npm install territory. CI pipelines, release builds, Docker images, and any automated environment exist to verify code, not to make dependency decisions. Re-resolving semver ranges in CI means the artifact you ship may not contain the dependencies you tested. That is npm ci territory.
The constraint that enforces this split is package-lock.json. The lockfile records the exact resolved version of every package in the tree, plus an integrity hash (typically SHA-512) for each tarball. Committing it is a prerequisite for everything below — if your lockfile is in .gitignore, neither command can give you reproducibility.
Supported behaviors compared
| Behavior | npm install | npm ci |
|---|---|---|
| Lockfile required | No — creates one if missing | Yes — fails without it |
| May modify lockfile | Yes, when resolving or updating | Never |
| Existing node_modules | Kept and updated incrementally | Deleted entirely, then rebuilt |
| Version selection | Resolves semver ranges from package.json | Installs exact locked versions |
| package.json / lockfile out of sync | Quietly reconciles (updates lockfile) | Fails with an error |
| Speed | Faster on repeat runs | Slower (full wipe and install) |
| Integrity hash verification | Yes, for locked entries | Yes, for all entries |
Trade-offs in practice
Why npm ci is worth the slowness in automation. Deleting node_modules before installing guarantees the resulting tree matches the lockfile exactly in terms of versions — no stale packages lingering from a previous run, no partial upgrades. The out-of-sync failure is a feature: if someone edits package.json and forgets to regenerate the lockfile, npm ci stops the build immediately instead of silently resolving something new. That surfaces drift at commit time rather than at deploy time.
Why npm install stays on your laptop. When you run npm install lodash or bump a version range, you want npm to resolve semver, update the lockfile, and reconcile the tree. Using npm ci locally would just fail, because it refuses to touch the lockfile.
Version compatibility matters. npm ci fails if the lockfile's lockfileVersion is incompatible with the npm doing the install. Teams should standardize on one npm major version — the one bundled with your chosen Node.js LTS release is the natural anchor. Flag names also shift across majors (for example, --omit=dev replaced the older --production flag), so check the docs for your version before copying flags into pipeline YAML.
A limit worth stating plainly. A lockfile pins versions and its integrity hashes detect tampered tarballs at download time, but it cannot protect you from a malicious version that was published before the lock was created. Lockfiles reduce supply-chain risk; they do not eliminate it. Pair them with npm audit and review dependency changes in pull requests.
Concrete implementation: a CI install step
A typical pipeline step (run in the repository root, with the same Node.js/npm version your team standardized on):
# Verify the toolchain matches team standard
npm --version
node --version
# Reproduce the locked tree exactly
npm ci
# Confirm a critical dependency resolved to the locked version
npm ls your-critical-package
# Check known vulnerabilities against the resolved tree
npm audit --audit-level=highExpected results: npm ci exits 0 with no lockfile modification; npm ls prints the exact version recorded in package-lock.json; npm audit reports the vulnerability status you have accepted for the project. If npm ci exits non-zero with an out-of-sync error, the fix is to run npm install locally, commit the regenerated lockfile, and push — never to bypass the check in CI.
Validating the setup
Three checks confirm the workflow is doing its job:
- Clean-checkout reproducibility. In a fresh clone, run
npm citwice. Both runs should succeed identically, andgit statusshould show no changes topackage-lock.json. - Drift detection. Deliberately edit a version range in
package.jsonwithout regenerating the lockfile, then runnpm ci. It should fail with an out-of-sync error. Revert the edit afterward. - Toolchain alignment. Compare
npm --versionacross developer machines and CI, and check thelockfileVersionfield at the top ofpackage-lock.json. Mismatches here cause confusing failures that look like lockfile corruption but are really version skew.
The rule that falls out of all of this fits in one line: humans changing dependencies run npm install; machines verifying code run npm ci. Everything else — the lockfile commits, the version standardization, and the validation checks — exists to keep that line bright.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.