Semantic versioning and dependency ranges
What MAJOR.MINOR.PATCH promises, how ^, ~, x, and hyphen ranges resolve, and how lockfiles fit in.
Every package.json, Cargo.toml, and pyproject.toml declares which versions of its dependencies it accepts. Those declarations decide what gets installed on a fresh machine or in CI, so misreading them is a classic source of “works on my machine” bugs. This guide explains Semantic Versioning and the range syntax used by npm and similar tools.
MAJOR.MINOR.PATCH
Semantic Versioning (SemVer) gives each part of a version number a meaning:
- MAJOR changes when a release breaks compatibility, for example 1.4.2 → 2.0.0.
- MINOR changes when features are added in a backward-compatible way, for example 1.4.2 → 1.5.0.
- PATCH changes for backward-compatible bug fixes, for example 1.4.2 → 1.4.3.
Pre-release versions add a hyphenated tag, such as 2.0.0-beta.1, and sort before the final release. Build metadata after a plus sign, such as 1.0.0+build.5, is ignored when comparing versions. Versions below 1.0.0 are a special case: the specification treats anything in 0.x as unstable, so even a minor bump may break things.
SemVer is a promise made by maintainers, not a guarantee. Bug fixes sometimes break code that relied on the bug, which is why lockfiles exist.
Range operators
| Range | Means | Example matches |
|---|---|---|
1.2.3 | Exactly this version | 1.2.3 only |
^1.2.3 | >=1.2.3 <2.0.0 | 1.2.4, 1.9.0, not 2.0.0 |
~1.2.3 | >=1.2.3 <1.3.0 | 1.2.9, not 1.3.0 |
^0.2.3 | >=0.2.3 <0.3.0 | 0.2.5, not 0.3.0 |
1.x or 1.* | >=1.0.0 <2.0.0 | any 1.y.z |
1.2.0 - 1.4.0 | >=1.2.0 <=1.4.0 | inclusive hyphen range |
>=1.2.0 <2.0.0 || 3.x | Either range | 1.5.0 and 3.1.0 |
The caret is npm’s default: npm install lodash writes ^4.17.21. It accepts new features and fixes but not new major versions. Below 1.0.0 the caret tightens automatically, because 0.x minor releases may break compatibility. Test any version and range with the SemVer range tester, which shows the decision and the comparison it made.
Pre-releases are a special case
Under plain SemVer ordering, 1.2.4-beta.1 is greater than 1.2.3, so it falls inside ^1.2.3. npm adds a protective rule: a range only matches a pre-release if one of its comparators refers to the same major.minor.patch with a pre-release tag. That stops users from receiving betas unexpectedly. The tester on this site applies SemVer ordering and documents that it does not reproduce npm’s full pre-release rule. For pre-release questions, confirm with npm view package versions or your package manager.
Ranges vs. lockfiles
A range says what is allowed. A lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock) records what was actually installed, down to the exact version and checksum. Commit the lockfile for applications, and use npm ci or pnpm install --frozen-lockfile in CI, so every build gets the same dependency tree. Ranges then matter mainly when you deliberately update, and for libraries, whose users resolve ranges against their own dependency trees.
Choosing ranges
- Applications: caret ranges plus a committed lockfile are the norm. Update deliberately with tools like Dependabot or Renovate.
- Libraries: use ranges as wide as your code genuinely supports, so users do not end up with duplicate copies of a dependency.
- Tilde or exact versions: for dependencies known to break things in minor releases, or for build tools whose output must be reproducible.
- Avoid
*andlatestin anything that matters. They accept breaking major releases.
A malformed manifest breaks installs before ranges even come into play. The package.json validator catches invalid versions, a wrong type value, and non-string scripts before they reach CI.
