"express": "^4.18.2" and "typescript": "~5.4.5" sit next to each other in thousands of package.json files, and most people could not say exactly which versions each one allows. The difference decides what a fresh npm install pulls in, which is why "works on my machine" bugs so often start there. This article explains Semantic Versioning, then reads npm's range syntax against one concrete list of versions, using the semver range checker for every result.
What a version number promises
Semantic Versioning 2.0.0 gives the three numbers of MAJOR.MINOR.PATCH a meaning:
- PATCH goes up for backwards-compatible bug fixes.
- MINOR goes up for backwards-compatible new features, and resets PATCH.
- MAJOR goes up for breaking changes, and resets the other two.
- 0.y.z is initial development: anything may change at any time.
A version can also carry a pre-release (1.4.0-beta.1), which is lower than the release it leads to, and build metadata (1.4.0+20261014), which is ignored when ordering. Pre-release parts compare left to right, numbers as numbers: beta.11 is higher than beta.2.
These are promises made by people, not guarantees, but npm's range syntax is built on them.
One package, ten ranges
Suppose a package has published these versions:
1.2.3, 1.2.9, 1.3.0, 1.4.0-beta.1, 1.4.0, 1.10.2, 2.0.0-rc.1, 2.0.0, 2.1.0
| Range | Means | Matching versions | npm install picks |
|---|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0-0 | 1.2.3, 1.2.9, 1.3.0, 1.4.0, 1.10.2 | 1.10.2 |
~1.2.3 | >=1.2.3 <1.3.0-0 | 1.2.3, 1.2.9 | 1.2.9 |
1.2.x | >=1.2.0 <1.3.0-0 | 1.2.3, 1.2.9 | 1.2.9 |
1.x | >=1.0.0 <2.0.0-0 | same as ^1.2.3 plus anything 1.0 to 1.2.2 | 1.10.2 |
>=1.2.3 <2 | >=1.2.3 <2.0.0-0 | same as ^1.2.3 | 1.10.2 |
1.2 - 1.4 | >=1.2.0 <1.5.0-0 | 1.2.3, 1.2.9, 1.3.0, 1.4.0 | 1.4.0 |
~1.4.0-beta.1 | >=1.4.0-beta.1 <1.5.0-0 | 1.4.0-beta.1, 1.4.0 | 1.4.0 |
^1.4.0-beta.1 | >=1.4.0-beta.1 <2.0.0-0 | 1.4.0-beta.1, 1.4.0, 1.10.2 | 1.10.2 |
^2.0.0-rc.1 | >=2.0.0-rc.1 <3.0.0-0 | 2.0.0-rc.1, 2.0.0, 2.1.0 | 2.1.0 |
* | any | every release, no pre-releases | 2.1.0 |
The pattern: caret keeps the major version, tilde keeps major and minor. Note that 1.10.2 is above 1.4.0: the numbers are compared as numbers, not text. And a hyphen range with a partial upper end, - 1.4, means "anything starting with 1.4", so it stops below 1.5.0, not at 1.4.0.
The pre-release rule
Look at the first row again: ^1.2.3 does not match 1.4.0-beta.1, although 1.4.0-beta.1 lies between 1.2.3 and 2.0.0. npm deliberately keeps pre-releases out unless you ask for them: a pre-release only matches a range that itself names a pre-release of the same major.minor.patch. That is why ^1.4.0-beta.1 matches 1.4.0-beta.1, but would not match a later 1.5.0-beta.1. The -0 at the end of upper bounds such as <2.0.0-0 exists for the same reason: it excludes every pre-release of 2.0.0 as well as 2.0.0 itself. npm's --include-prerelease flag (the checkbox in the tool) turns the rule off.
Zero versions are stricter
Before 1.0.0 the caret treats the minor number as the major one, because in 0.x anything can break:
| Range | Means | From 0.0.3, 0.0.4, 0.4.1, 0.4.5, 0.5.0 |
|---|---|---|
^0.4.1 | >=0.4.1 <0.5.0-0 | 0.4.1, 0.4.5 |
~0.4.1 | >=0.4.1 <0.5.0-0 | 0.4.1, 0.4.5 |
^0.0.3 | >=0.0.3 <0.0.4-0 | 0.0.3 only |
~0.0.3 | >=0.0.3 <0.1.0-0 | 0.0.3, 0.0.4 |
So for 0.0.x the caret is stricter than the tilde, the opposite of what people expect.
Choosing the next version
When you publish, npm's version command moves the numbers for you. From 1.4.0-beta.1, "patch" and "minor" both give 1.4.0 (they finish the pre-release rather than skipping past it), "major" gives 2.0.0, and "prerelease" gives 1.4.0-beta.2. From 1.3.0, "preminor beta" gives 1.4.0-beta.0 and "prerelease rc" gives 1.3.1-rc.0. The bump tab of the tool lists all seven at once.
Common mistakes
- Assuming ^ means "latest". It means "latest with the same major", and for 0.x even less.
- Relying on ranges without a lock file. Commit package-lock.json; ranges then only matter when you update deliberately.
- Expecting betas to install. They will not, unless the range names one of the same x.y.z or you opt in.
- Comparing versions as strings. "1.10.0" < "1.9.0" in text order; in SemVer it is higher.
- Leading zeros and a fourth number.
1.02.0and1.2.3.4are not valid SemVer and npm rejects them.
Try it
Paste the output of npm view <package> versions into the semver range checker, type the range from your package.json, and see which version a fresh install would get and why each other version was left out. To check that the package.json itself is valid, use the JSON formatter.