Semantic Versioning and package-lock.json

npm doesn’t just download packages — it has to decide which version of every package, and every one of that package’s own dependencies, to install. Semantic Versioning (semver) is the numbering scheme that lets package authors and npm communicate what a version bump means, and the ranges in package.json tell npm which versions you’re willing to accept. But ranges alone aren’t enough for reproducibility: two installs run a week apart could resolve a range like ^4.19.2 to two different actual versions. That’s what package-lock.json is for — it freezes the exact result of dependency resolution so npm install produces an identical node_modules tree on every machine and every CI run.

Overview / How it works

A semantic version has the form MAJOR.MINOR.PATCH, for example 4.19.2. By convention (npm enforces nothing here beyond parsing the format — following the meaning is on package authors):

  • MAJOR increments on breaking API changes.
  • MINOR increments on backward-compatible new features.
  • PATCH increments on backward-compatible bug fixes.

Versions can also carry a prerelease tag (2.0.0-beta.1) and build metadata (2.0.0+20260731), which most tooling treats as lower precedence than the plain release.

When you write a dependency in package.json, you almost never write an exact version — you write a range, like ^4.19.2. When you run npm install, npm asks the registry (registry.npmjs.org by default) which published versions exist, picks the highest one that satisfies every range in your dependency graph, downloads it, and repeats the process recursively for that package’s own dependencies. This is why two separate npm install runs, days apart, against the exact same package.json, can pull in different actual versions — a new patch or minor release may have been published in between, and your range happily accepts it.

package-lock.json exists to remove that non-determinism. The first time you run npm install, npm writes every resolved version, its exact download URL, and a cryptographic integrity hash into package-lock.json. On every subsequent install, as long as package.json hasn’t changed in a way that breaks the lock, npm reuses those exact resolved versions instead of re-resolving ranges against the registry. Modern npm (v7+) writes lockfileVersion: 3, a flat packages map keyed by node_modules path, which is both faster to read and gives a complete picture of the dependency tree in one file — you should never need to open node_modules to know what’s actually installed.

Syntax

Range operators you’ll see in package.json dependency fields:

Syntax Meaning
4.19.2 Exact version only.
^4.19.2 Compatible with 4.19.2 — allows minor and patch bumps, i.e. >=4.19.2 <5.0.0. This is npm’s default when you run npm install pkg.
~4.19.2 Approximately 4.19.2 — allows patch bumps only, i.e. >=4.19.2 <4.20.0.
4.x / 4.19.x Wildcard for any minor/patch within that major (or patch within that minor).
* Any version at all — avoid this; it defeats reproducibility.
>=1.3.0 <2.0.0 Explicit range with comparison operators.
1.2.3 || 2.x Either range is acceptable.

One special case worth memorizing: for a 0.x.y version, npm treats the leading zero as “unstable API” per the semver spec, so ^0.5.2 only allows patch bumps (>=0.5.2 <0.6.0), not minor ones — the caret behaves like a tilde until you reach 1.0.0.

Examples

Example 1: Reading the ranges in a real package.json

{
  "name": "demo-app",
  "version": "1.4.2",
  "dependencies": {
    "express": "^4.19.2",
    "lodash": "~4.17.21",
    "chalk": "5.3.0",
    "left-pad": ">=1.3.0 <2.0.0",
    "typescript": "*"
  }
}

Here express can float up to (but not including) 5.0.0, lodash can only take patch updates within 4.17.x, chalk is pinned exactly, left-pad uses an explicit comparison range, and typescript accepts literally anything published — which is risky, since a breaking major release would be installed silently. package-lock.json is what actually pins these down to one resolved version each; the ranges above only describe what’s acceptable.

Example 2: Testing ranges programmatically with the semver package

npm install semver
const semver = require("semver");

const installed = "2.4.1";
const required = "^2.3.0";

console.log(`Does ${installed} satisfy ${required}? ${semver.satisfies(installed, required)}`);
console.log(`Does 3.0.0 satisfy ${required}? ${semver.satisfies("3.0.0", required)}`);
console.log(`Next minor after ${installed}: ${semver.inc(installed, "minor")}`);

Output:

Does 2.4.1 satisfy ^2.3.0? true
Does 3.0.0 satisfy ^2.3.0? false
Next minor after 2.4.1: 2.5.0

The semver package is the same range-matching engine npm itself uses internally. satisfies() answers exactly the question npm asks during resolution: does this published version fall inside this range? This is useful in your own tooling too — for example, validating a plugin’s declared peerDependencies range before loading it.

Example 3: Verifying package.json and package-lock.json agree

const fs = require("node:fs/promises");
const path = require("node:path");

async function main() {
  const pkgPath = path.join(__dirname, "package.json");
  const lockPath = path.join(__dirname, "package-lock.json");

  const [pkgRaw, lockRaw] = await Promise.all([
    fs.readFile(pkgPath, "utf8"),
    fs.readFile(lockPath, "utf8"),
  ]);

  const pkg = JSON.parse(pkgRaw);
  const lock = JSON.parse(lockRaw);

  if (pkg.version !== lock.version) {
    console.warn(
      `Version mismatch: package.json is ${pkg.version}, package-lock.json is ${lock.version}. Run "npm install" to sync them.`
    );
  } else {
    console.log(`In sync at version ${pkg.version} (lockfileVersion ${lock.lockfileVersion}).`);
  }
}

main().catch((err) => {
  console.error("Could not verify lockfile:", err.message);
  process.exitCode = 1;
});

Output:

In sync at version 1.4.2 (lockfileVersion 3).

This is a realistic pre-deploy sanity check: it reads both files with fs/promises (never block a server’s event loop with the sync reader, but a one-off startup script is a fine place for it), parses them, and flags drift. Note the plain __dirname — this script is CommonJS, so it’s available directly; in an ES module you’d need import.meta.dirname instead, since __dirname doesn’t exist there.

Under the hood: how npm resolves and locks versions

  1. npm reads every range in package.json (and, transitively, in every installed package’s own package.json).
  2. For each range, npm queries the registry’s metadata for that package and picks the highest version satisfying the range that isn’t deprecated.
  3. If a package-lock.json already exists and still satisfies every current range, npm reuses its previously resolved versions instead of re-querying the registry — this is what makes repeat installs fast and deterministic.
  4. npm builds a dependency tree, deduplicating shared packages up to the top-level node_modules where version ranges allow it, and nesting a package’s own node_modules only when a conflicting version is required.
  5. The final resolved graph — exact versions, tarball URLs, and integrity hashes (subresource-integrity-style SHA-512 digests) — is written to package-lock.json.
  6. npm ci skips resolution entirely: it reads package-lock.json as the single source of truth, deletes any existing node_modules, and installs exactly what the lockfile says — failing loudly if package.json and the lockfile have drifted apart.

A trimmed package-lock.json entry looks like this:

{
  "name": "demo-app",
  "version": "1.4.2",
  "lockfileVersion": 3,
  "packages": {
    "": {
      "name": "demo-app",
      "version": "1.4.2",
      "dependencies": { "express": "^4.19.2" }
    },
    "node_modules/express": {
      "version": "4.19.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
      "integrity": "sha512-abc123examplehashdonotusevaluesarefakefordemopurposesonly=="
    }
  }
}

Every installed package gets one of these entries, keyed by its path under node_modules. The resolved URL and integrity hash are what let npm skip re-downloading and re-verifying already-installed packages, and what let npm ci detect tampering — if a downloaded tarball’s hash doesn’t match, the install fails.

Common Mistakes

1. Running npm install in CI instead of npm ci. npm install will happily update package-lock.json if it finds a newer version satisfying a range, meaning your CI could install different versions than what a teammate has locally — and silently rewrite the lockfile in the process.

# Risky in CI: can re-resolve ranges and rewrite the lockfile
npm install

# Correct in CI: installs exactly what's in package-lock.json, fails fast on drift
npm ci

2. Hand-editing package-lock.json. Changing a version number directly in the lockfile does not update the corresponding tarball, its dependencies, or the integrity hash — the file becomes internally inconsistent and npm ci will typically reject it. Always let npm regenerate the lockfile.

# Wrong: manually editing the resolved version in package-lock.json

# Right: ask npm to do the resolution and rewrite the lockfile for you
npm install express@^4.20.0

3. Assuming ^ always allows minor bumps. For pre-1.0 packages, ^0.5.2 behaves like ~0.5.2 — only patch releases are accepted, because a 0.x major means the API is still considered unstable under semver’s own rules. Developers frequently get bitten expecting a 0.x dependency to pick up new minor features automatically.

4. Not committing package-lock.json. Adding it to .gitignore (common when copying a Python-style ignore list) throws away the whole benefit — every teammate and CI run goes back to re-resolving ranges independently, and “works on my machine” bugs creep back in.

Best Practices

  • Always commit package-lock.json to version control — it belongs in the repo, not the ignore list.
  • Use npm ci, not npm install, in CI/CD pipelines and Docker builds — it’s faster and fails loudly instead of drifting silently.
  • Never hand-edit package-lock.json; change the range in package.json and let npm re-resolve it.
  • Run npm outdated periodically to see which installed versions are behind what your ranges would allow, and what’s behind the latest release entirely.
  • Run npm audit alongside version checks — a version bump is often how a known vulnerability gets fixed.
  • Prefer ^ ranges for application dependencies (the npm default) so you get bug fixes automatically, but pin exact versions for tools where an unexpected bump is costly (build toolchains, linters with strict configs).
  • If you truly need every install to be byte-for-byte pinned, set save-exact=true in .npmrc rather than manually typing exact versions everywhere.
  • Don’t mix lockfile formats in one project — pick npm (package-lock.json) or Yarn (yarn.lock) or pnpm (pnpm-lock.yaml), never commit more than one.

Practice Exercises

1. Create a fresh project with npm init -y, then run npm install chalk@^5.0.0 followed by npm install chalk@~5.0.0 in a second, separate project. Open both package-lock.json files and compare the resolved version field for chalk — explain in your own words why they might differ.

2. In a project with a committed package-lock.json, manually edit a version number inside package.json to something the lockfile doesn’t satisfy, then run npm ci. Observe and explain the error message npm gives you.

3. Using the semver package, write a small script that reads your own project’s package.json engines.node field (add one if it’s missing, e.g. ">=20.0.0") and uses semver.satisfies(process.version, range) — note process.version includes a leading “v” that semver needs stripped, e.g. with semver.clean() — to print whether the currently running Node.js version meets the requirement.

Summary

  • Semantic versions are MAJOR.MINOR.PATCH; by convention MAJOR breaks compatibility, MINOR adds features, PATCH fixes bugs.
  • package.json dependency ranges (^, ~, exact, comparison ranges, *) describe what’s acceptable, not what’s actually installed.
  • ^0.x.y only allows patch bumps — pre-1.0 packages are treated as unstable by semver’s own rules.
  • package-lock.json freezes the exact resolved versions, download URLs, and integrity hashes so installs are reproducible across machines and time.
  • npm ci installs strictly from the lockfile and fails on drift; npm install can re-resolve and rewrite the lockfile — use ci in automated pipelines.
  • Always commit the lockfile, never hand-edit it, and use npm outdated / npm audit to stay on top of what versions you’re actually running.