Semantic versioning and dependency ranges explained

7 min read
semver
dependencies

Semantic Versioning (semver) is a convention — not a language feature — for encoding what kind of change a new package version contains, using a `MAJOR.MINOR.PATCH` number. Package managers rely on that convention to decide which updates are safe to install automatically, which is exactly why a package that doesn't follow it correctly can silently break consumers.

What each number is supposed to mean

  • **MAJOR** — incremented for breaking changes: anything that could require consumers to change their own code to keep working.
  • **MINOR** — incremented for backward-compatible new functionality: new features, new optional parameters, nothing existing consumers need to change.
  • **PATCH** — incremented for backward-compatible bug fixes: no new functionality, no breaking changes, just corrections.

Going from `2.4.1` to `2.5.0` should mean "new features, nothing breaks." Going from `2.4.1` to `3.0.0` should mean "something you rely on may have changed behavior." This is a promise the package author makes, not something enforced by tooling — a badly-versioned package can absolutely ship a breaking change in a patch release, which is the single most common cause of "it worked yesterday" dependency incidents.

Pre-release and build metadata

`1.0.0-beta.1` and `1.0.0-rc.2` are pre-release versions; semver defines them as having lower precedence than the plain `1.0.0` release, so `1.0.0-beta.1 < 1.0.0`. Build metadata (`1.0.0+build.5`) is informational and ignored when comparing precedence.

Caret (^) vs tilde (~) ranges

These two range operators are where most confusion happens in `package.json`:

  • **`^1.2.3`** (caret) — allows any version that doesn't change the leftmost non-zero number. For `^1.2.3`, that means `>=1.2.3 <2.0.0` — minor and patch updates are allowed, major is not. This is the default that `npm install` writes to `package.json`.
  • **`~1.2.3`** (tilde) — allows patch-level changes only: `>=1.2.3 <1.3.0`. Minor updates are excluded.

The "leftmost non-zero" rule for caret matters for versions before 1.0.0, where it behaves more conservatively since a 0.x release is still considered unstable by semver convention:

  • `^1.2.3` → `>=1.2.3 <2.0.0` (normal case)
  • `^0.2.3` → `>=0.2.3 <0.3.0` (treats the minor version like a major for 0.x)
  • `^0.0.3` → `>=0.0.3 <0.0.4` (treats the patch version as the only safe-to-vary digit)

That last case is the one people get caught by: for a `0.0.x` package, caret behaves almost like an exact pin, because semver considers any change in a 0.x.y package potentially breaking.

Other range syntax you'll encounter

  • **Exact pin** — `1.2.3` with no prefix, installs exactly that version and nothing else.
  • **`>=`, `<=`, `>`, `<`** — explicit comparison operators, combinable: `>=1.2.0 <2.0.0`.
  • **`x` or `*` wildcards** — `1.2.x` means any patch version of `1.2`; `*` means any version at all (rarely a good idea).
  • **`-` ranges** — `1.2.3 - 2.3.4` means an inclusive range between those two versions.
  • **`||`** — combines multiple ranges: `^1.0.0 || ^2.0.0` accepts either major line.

Why lockfiles exist despite ranges

`package.json` ranges describe what's *acceptable*; a lockfile (`package-lock.json`, `yarn.lock`, `bun.lock`) records the *exact* resolved version tree that was installed at a point in time. Without a lockfile, two installs of the same `package.json` — on different days, or on different machines — can resolve to different actual versions if anything in the allowed range published a new release in between, since `^` and `~` are ranges, not pins. Committing the lockfile is what makes builds reproducible; the range in `package.json` is only a policy for what future updates are allowed.

Practical guidance

  • Use caret ranges (the default) for most dependencies — you get patch and minor updates without needing to bump anything manually.
  • Use exact pins for anything where an unreviewed update has caused real incidents, or for build tooling where reproducibility matters more than staying current.
  • Always commit the lockfile, and treat lockfile changes in a diff as worth reviewing, not noise to ignore.
  • Don't assume a dependency's version bump is safe just because it's "only" a minor or patch release — semver is a promise the author has to keep correctly, and not every package does.

When you need to check whether one version satisfies a given range, or compare two version strings against semver ordering rules, the [semver checker](/tools/dev-utils/semver-checker) does exactly that without needing to spin up a Node script.

Tools from this article

← All articles