What you'll learn
Quick Answer
Semantic versioning is a MAJOR.MINOR.PATCH numbering scheme where each position has a specific meaning: MAJOR for breaking changes, MINOR for backward-compatible new features, and PATCH for backward-compatible bug fixes. The promise is that you can gauge the risk of an upgrade from the version number alone, without reading the changelog. The catch is that nothing enforces this — it's a convention authors have to follow correctly, and npm's own caret range treats versions below 1.0.0 differently in a way that surprises most people the first time they hit it.
The three numbers, and what actually triggers each one
A version like 4.18.2 is read right to left in terms of risk: PATCH (the 2) means a bug was fixed with no change to how you use the library. MINOR (the 18) means something was added — a new function, a new option — without removing or changing anything existing. MAJOR (the 4) means something that used to work might not anymore: a renamed function, a changed default, a dropped feature.
The point of the scheme is that you should be able to decide how carefully to test an upgrade just from which number changed, before reading a single line of the changelog. A patch bump should be safe to accept automatically. A major bump should get a deliberate look.
The important caveat: this is entirely a human promise, not something any tool enforces. Nothing stops a maintainer from publishing a breaking change as a minor version by mistake, or on purpose because they didn't consider it breaking. Semantic versioning tells you what the author intended, which is useful information, but it is not a guarantee.
Caret (^) vs tilde (~): what npm install will actually accept
In a package.json, the symbol in front of a version controls how much npm is allowed to upgrade automatically on npm install. The two you'll see constantly are caret and tilde, and they allow different amounts of movement:
semver.satisfies('1.2.3', '^1.2.3'); // true
semver.satisfies('1.3.0', '^1.2.3'); // true -- minor bump allowed
semver.satisfies('1.9.9', '^1.2.3'); // true -- still allowed
semver.satisfies('2.0.0', '^1.2.3'); // false -- major bump blocked
semver.satisfies('1.2.4', '~1.2.3'); // true -- patch bump allowed
semver.satisfies('1.2.9', '~1.2.3'); // true
semver.satisfies('1.3.0', '~1.2.3'); // false -- minor bump blocked by tildeCaret (^) allows anything that doesn't bump the major version — it trusts the MINOR/PATCH promise fully. Tilde (~) is stricter, allowing only patch-level bumps. Caret is npm's default when you run npm install some-package, which is why most package.json files are full of ^ ranges without anyone typing the symbol deliberately.
The gotcha: caret behaves differently below 1.0.0
Here's the rule almost nobody expects the first time they read the semver spec closely: below version 1.0.0, a package is considered to have no stability guarantee yet, so npm's caret becomes deliberately more conservative:
// Caret on a 0.x.y version only allows PATCH bumps, not MINOR
semver.satisfies('0.2.4', '^0.2.3'); // true
semver.satisfies('0.2.9', '^0.2.3'); // true
semver.satisfies('0.3.0', '^0.2.3'); // false -- most people expect true here
// Caret on a 0.0.x version locks to that EXACT version
semver.satisfies('0.0.3', '^0.0.3'); // true
semver.satisfies('0.0.4', '^0.0.3'); // false -- even a patch bump is rejectedFor a normal 1.x.y package, ^1.2.3 allows the minor version to climb freely. For a 0.x.y package, ^0.2.3 only allows the patch number to move — a jump from 0.2.9 to 0.3.0 is treated the same way a jump from 1.9.9 to 2.0.0 would be. And below that, once you're at 0.0.x, caret locks to the exact version with zero room to move at all.
This has a very concrete real-world consequence. A library sitting at 0.4.0 can ship what most developers would call a routine update to 0.4.1 that still contains a breaking change, and that's technically within the rules — the entire 0.x.y range is the semver spec's way of saying "nothing is stable yet, all bets are off." If a dependency has been sitting on 0.x for years, treat every upgrade to it as a major upgrade in disguise, because the version number is telling you the author hasn't committed to stability either.
Pre-release tags and build metadata
Two more pieces of the spec show up less often but matter when they do. A pre-release tag (-beta.1, -rc.2) marks a version as not yet the real release, and it sorts before the plain version it's attached to:
semver.lt('1.0.0-beta.1', '1.0.0'); // true -- pre-release sorts before the release
semver.lt('1.0.0-alpha', '1.0.0-beta'); // true -- alpha sorts before beta
semver.compare('1.0.0-alpha.1', '1.0.0-alpha.2'); // -1 (alpha.1 is lower)
semver.eq('1.0.0+build.1', '1.0.0+build.2'); // true -- build metadata is ignoredThis lets a maintainer publish 2.0.0-beta.1 to a beta npm dist-tag for early testers, while everyone on a normal ^1.x range never sees it — a pre-release version doesn't satisfy a range that doesn't explicitly ask for pre-releases. Build metadata (+build.1234) is different again: it's purely informational and ignored entirely when comparing versions for precedence, as the last line above shows — two versions differing only in build metadata are considered equal.
Why your lockfile matters more than your version range
A version range in package.json is a request, not a guarantee of what gets installed. The lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock) is what actually pins every dependency, direct and transitive, to one exact resolved version. If that file isn't committed, or if someone runs npm install in a way that updates it, two machines running "the same" package.json can end up with different minor versions of a transitive dependency installed — the textbook cause of a bug that only reproduces on one developer's laptop or only in CI.
The practical fix: always commit the lockfile, and use npm ci rather than npm install in CI and deploy pipelines. npm ci installs exactly what the lockfile specifies and fails outright if the lockfile and package.json disagree, instead of silently resolving a new set of versions. Version ranges are for humans deciding how much automatic movement to allow; the lockfile is what guarantees everyone is actually running the same code.
