npm
npm Caret vs Tilde: What ^1.2.3 and ~1.2.3 Really Allow (and the 0.x Exception)
^ allows minor and patch updates, ~ allows only patch, and below 1.0.0 the rules change. Every range resolved, plus why the lockfile usually wins.
You open a package.json and see "express": "^4.18.2" next to "lodash": "~4.17.21". One character apart, and they promise different things. A caret lets minor and patch versions float, a tilde lets only the patch float, and below 1.0.0 the caret tightens until it acts like a tilde (or tighter). But the range only decides what a fresh install could pick. Your lockfile decides what you actually get, which is why most “the caret broke my build” stories are really lockfile stories.
Every interval below was produced by running the range through semver.validRange() from the copy of semver (7.7.3) bundled inside npm 11.7.0, the same library npm uses to resolve ranges. The rules themselves come from the node-semver README.
The table
| Range in package.json | Resolves to | Plain English |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0-0 | Any 1.x at or above 1.2.3 |
~1.2.3 | >=1.2.3 <1.3.0-0 | Any 1.2.x at or above 1.2.3 |
^0.2.3 | >=0.2.3 <0.3.0-0 | Any 0.2.x at or above 0.2.3 (same as ~0.2.3) |
^0.0.3 | >=0.0.3 <0.0.4-0 | Effectively only 0.0.3 |
1.2.x | >=1.2.0 <1.3.0-0 | Any 1.2.x, including 1.2.0 |
>=1.2.3 <2 | >=1.2.3 <2.0.0-0 | Identical to ^1.2.3, written longhand |
1.2.3 | 1.2.3 | Exactly 1.2.3, nothing else |
Given the published versions 1.2.3, 1.9.0 and 2.0.0, semver.maxSatisfying() picks 1.9.0 for ^1.2.3. Given 1.2.3, 1.2.9 and 1.3.0, it picks 1.2.9 for ~1.2.3. That is the whole difference in practice: how far a fresh resolve is allowed to drift.
What the -0 on the upper bound means
The -0 is the lowest possible prerelease of that version. Without it, <2.0.0 would let 2.0.0-beta.1 through whenever prereleases are allowed (the includePrerelease option), because it sorts below 2.0.0. By default prereleases are excluded anyway: the README says a prerelease version only satisfies a range “if at least one comparator with the same [major, minor, patch] tuple also has a prerelease tag.” So 2.0.0-beta.1 and 1.3.0-beta.1 both fail ^1.2.3. We checked both with semver.satisfies().
The caret rule, stated exactly
People describe ^ as “compatible with the major version.” That’s true only from 1.0.0 up. The README’s actual definition:
Allows changes that do not modify the left-most non-zero element in the
[major, minor, patch]tuple.
So the caret locks whichever component is the first non-zero one:
^1.2.3: the first non-zero is the major (1), so minor and patch float.^0.2.3: the major is 0, so the first non-zero is the minor (2). Only the patch floats.^0.0.3: major and minor are both 0, so the patch (3) is locked. Nothing floats.
The reason is the Semantic Versioning spec itself: “Major version zero (0.y.z) is for initial development. Anything MAY change at any time.” A library at 0.4 is allowed to break you in 0.5, so npm treats each 0.x minor as if it were a major.
The npm docs show this with npm update. On the npm update page, a "dep1": "^0.2.0" dependency stays at 0.2.0 even though 0.4.1 and 1.2.2 are published. Change it to ^0.4.0 and npm update installs 0.4.1, “the highest-sorting version that satisfies ^0.4.0 (>= 0.4.0 <0.5.0).”
The tilde rule, and where it is looser than the caret
The README defines ~ as: “Allows patch-level changes if a minor version is specified on the comparator. Allows minor-level changes if not.” The tilde doesn’t care about zeros. It only cares about how many parts you wrote:
| Range | Resolves to |
|---|---|
~1.2.3 | >=1.2.3 <1.3.0-0 |
~1.2 | >=1.2.0 <1.3.0-0 |
~1 | >=1.0.0 <2.0.0-0 (same as 1.x) |
~0.2.3 | >=0.2.3 <0.3.0-0 |
~0.0.3 | >=0.0.3 <0.1.0-0 |
Look at the last row. On a 0.0.x package, ~0.0.3 accepts 0.0.9, but ^0.0.3 doesn’t. That’s the one place the tilde is the looser of the two. And ~1 is just as wide as ^1.0.0. “Tilde is always safer” only holds for full three-part versions at 1.0.0 and above.
Where ^ comes from in the first place
You rarely type the caret yourself. npm’s save-prefix config has a default of "^", per the npm config docs, so npm install express writes "express": "^4.x.y". If you want exact versions in package.json, npm install --save-exact (or save-exact=true in .npmrc) writes the bare version with no prefix.
Why the lockfile usually wins
The range is a statement of what you’d accept. package-lock.json is a record of what you got. The package-lock.json docs say it “describes the exact tree that was generated, such that subsequent installs are able to generate identical trees, regardless of intermediate dependency updates.”
The npm install docs spell out how the two interact:
- If the lockfile’s resolved versions satisfy the
package.jsonranges, npm uses the exact versions from the lockfile. - If they don’t (say you changed
^7.0.0to^8.0.0), npm resolves new versions and updates the lockfile. - In the docs’ words: “When the lockfile’s versions satisfy the package.json ranges, the lockfile wins.”
npm ci is stricter. It requires an existing package-lock.json or npm-shrinkwrap.json, exits with an error if the lock and package.json disagree, deletes any existing node_modules first, and “will never write to package.json or any of the package-locks.”
So when a ^ dependency seems to change under you, the question is almost never “what does the caret allow?” It is one of these:
- The lockfile isn’t committed, so every machine resolves the range fresh and picks whatever is newest that day.
- CI runs
npm installinstead ofnpm ci, so a lock that’s out of sync withpackage.jsongets quietly re-resolved instead of failing loudly. - Someone ran
npm update, which by design moves packages to the newest version inside the range. Per the npm docs, it doesn’t touch the ranges inpackage.jsonunless you pass--save. - You’re a library author. The lock “cannot be published, and it will be ignored if found in any place other than the root project,” according to the package-lock docs. Your users resolve your ranges fresh, against their own tree.
Point 4 is why ranges still matter. In an app, the lockfile pins everything. In a published package, your ^ is the only constraint your users get.
Picking a range
- App with a committed lockfile and
npm ciin CI: the default^is fine. The lock pins everything, and the range only matters when you deliberately update. - Dependency at 0.x: remember that
^0.4.1already means “0.4.x only.” You don’t need a tilde to be careful. - Dependency that breaks things in patch releases: pin it exactly (
--save-exact) and update on purpose. - Library you publish: use
^for real dependencies so users can dedupe, and keep ranges as wide as you actually tested.
Checking what you really have
When you need to see which version a lockfile actually resolved, diff the before and after. Paste the old and new package-lock.json into our JSON diff tool. It compares them structurally, ignoring key order, and lists added, removed and changed keys by path, so a version bump shows up as one changed version entry under its node_modules/... key.
To see which ranges a project declares, the Package.json Explorer lays out the name, version, license, scripts, and the dependencies, devDependencies and peerDependencies in tables with npm links. It shows the range strings exactly as written. It doesn’t resolve them, so use the table above to read them.
For vulnerabilities, feed the dependency scanner your package-lock.json or yarn.lock, not package.json. A lockfile lists exact resolved versions. Given a bare package.json, the scanner strips the range operator and checks the floor version (^4.17.0 is checked as 4.17.0), and it flags that report as approximate. Your real install is probably newer than that floor, so it may already include the fix. How to triage what npm audit and OSV report covers the next step.