npm, package.json, and semantic versioning

The caret and tilde in a package.json version aren't cosmetic — they encode a real, specific policy about which future versions npm is allowed to auto-install, and misreading them is a real, common source of "it worked yesterday" bugs.

Beginner

3 min read

package.json: the manifest describing a Node project

{
  "name": "my-api",
  "version": "1.2.0",
  "scripts": {
    "start": "node server.js",
    "test": "vitest run"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "vitest": "^1.0.0"
  }
}

package.json is the manifest every npm-managed project has: the project's own identity (name, version), named shortcuts to run (scripts, invoked as npm run test or the shorter npm test), and two separate dependency lists — dependencies (needed for the project to actually run) and devDependencies (needed only for development — testing, building, linting — never shipped to production). This split matters practically: npm install --production (or npm ci --omit=dev) installs only dependencies, skipping devDependencies entirely, which keeps a production deployment's installed package footprint smaller.

Semantic versioning: MAJOR.MINOR.PATCH, each number meaning something specific

4.18.2
│  │  └── PATCH — bug fixes only, no new features, no breaking changes
│  └───── MINOR — new features added, but backward-COMPATIBLE (old code still works)
└──────── MAJOR — BREAKING changes — old code may need updates to keep working

Semantic versioning (semver) is a real, widely-adopted convention (not a language feature) where each of the three numbers signals a specific kind of change: a PATCH bump means only bug fixes, safe to take without review; a MINOR bump adds functionality without breaking existing usage; a MAJOR bump means something that previously worked might now require code changes. This convention is what makes automated dependency updates even remotely safe to reason about — the version number itself is a real signal about risk, not just an incrementing counter.

^ and ~: how much auto-updating a dependency range actually allows

"express": "^4.18.2"   // allows 4.18.3, 4.19.0, 4.99.0 — anything up to (but not including) 5.0.0
"express": "~4.18.2"   // allows 4.18.3, 4.18.9 — only PATCH updates, not 4.19.0
"express": "4.18.2"    // exact version only — no auto-updates at all

^ (caret, npm's default when you npm install) permits any MINOR or PATCH update but never a MAJOR one — the reasoning directly follows semver's contract: minor and patch updates are supposed to be backward-compatible, so allowing them automatically is a deliberate, calculated risk, while a major update is explicitly allowed to break things, so it's never taken automatically. ~ (tilde) is more conservative, permitting only PATCH updates. An exact version pins to precisely one version, taking zero automatic updates at all — the strictest, most predictable, and most maintenance-heavy option.

package-lock.json: the exact versions actually installed, for reproducibility

package.json says:      "express": "^4.18.2"  — a RANGE of acceptable versions
package-lock.json says:  "express": "4.19.2"    — the EXACT version actually resolved and installed

package.json's version ranges describe what's acceptable, but two different npm install runs at different times could legitimately resolve ^4.18.2 to two different actual versions if a new compatible release shipped in between — package-lock.json records the exact, specific version tree that was actually installed, so every developer (and CI run, and production deploy) installing from the same lockfile gets identical dependency versions, not just "something compatible." This is why package-lock.json is meant to be committed to version control, and why npm ci (which installs strictly from the lockfile, failing if it's out of sync with package.json, rather than resolving fresh ranges like npm install does) is the standard choice for CI and production installs specifically for that reproducibility guarantee.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the practical difference between `dependencies` and `devDependencies` in package.json?

2. What does each number in a semantic version like `4.18.2` actually signal?

3. Why is `package-lock.json` committed to version control, and why does `npm ci` use it strictly?