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 thatnpm installwrites topackage.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.3with no prefix, installs exactly that version and nothing else. >=,<=,>,<— explicit comparison operators, combinable:>=1.2.0 <2.0.0.- **
xorwildcards* —1.2.xmeans any patch version of1.2;*means any version at all (rarely a good idea). -ranges —1.2.3 - 2.3.4means an inclusive range between those two versions.||— combines multiple ranges:^1.0.0 || ^2.0.0accepts 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 does exactly that without needing to spin up a Node script.