Semantic versioning and dependency ranges explained
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.