Semantic Versioning Ranges Explained: Caret, Tilde, and the npm/pnpm/yarn Operators
Last updated:
Semver — short for Semantic Versioning 2.0.0 — specifies a version number as MAJOR.MINOR.PATCH: three non-negative integers that signal breaking changes, new features, and bug fixes respectively. In package.json, you rarely write a bare version; you write a range like ^1.2.3, ~1.2.3, or >=1.2.3 <2. The range tells npm, pnpm, or yarn which versions are acceptable when it resolves your install. Most teams default to ^because it follows semver's compatibility promise, but exact pins are correct for tooling where reproducibility outweighs bug fixes.
Need to inspect or validate a package.json file with ranges? Paste it into Jsonic's JSON Validator — it pinpoints syntax errors with line and column numbers.
MAJOR.MINOR.PATCH: what each bump means
A semver version is exactly three numeric segments, separated by dots. Each segment has a strict meaning that maintainers are expected to honor:
| Segment | Bumped when | Consumer impact | Example |
|---|---|---|---|
| MAJOR | Incompatible API change | You may need to update your code | 1.4.2 → 2.0.0 |
| MINOR | Backward-compatible new feature | Safe to install; new functionality available | 1.4.2 → 1.5.0 |
| PATCH | Backward-compatible bug fix | Safe to install; no API changes | 1.4.2 → 1.4.3 |
Two optional suffixes extend the format: pre-release (hyphen + identifiers, e.g. 1.0.0-beta.1) and build metadata (plus + identifiers, e.g. 1.0.0+sha.5114f85). Build metadata is ignored for version precedence; pre-release tags are NOT — they sort before the corresponding final release (1.0.0-beta.1 < 1.0.0).
The whole point of semver is that the version number is a contract. When a maintainer ships 2.0.0, they are telling you to read the changelog. When they ship 1.5.0, they are telling you it is safe to install. Range operators in package.json exist to automate that trust: you grant up-front permission to install certain bumps without re-evaluating.
Range operators side-by-side: ^, ~, *, >=, <, exact, ||, -
npm uses the node-semver library to parse ranges. Below is every operator you will encounter in a real package.json, with the expansion the resolver actually applies.
| Operator | Example | Expands to | Use when |
|---|---|---|---|
Caret ^ | ^1.2.3 | >=1.2.3 <2.0.0 | Default for runtime libraries |
Tilde ~ | ~1.2.3 | >=1.2.3 <1.3.0 | Patches only; lock the minor |
| Exact | 1.2.3 | =1.2.3 | Build tools; reproducibility-critical |
Wildcard * / x | 1.2.x or 1.x or * | Any value at that segment | Almost never; * is dangerous |
| Comparator | >=1.2.3 <2.0.0 | Itself (literal) | Explicit cross-version ranges |
Hyphen - | 1.2.3 - 1.5.0 | >=1.2.3 <=1.5.0 | Inclusive on both ends |
OR || | ^1.0.0 || ^2.0.0 | Either range matches | Peer deps spanning majors |
| Latest tag | latest | Whatever the registry tags as latest | Avoid in committed package.json |
| Git URL | github:user/repo#sha | Clone at that ref | Fork or unpublished package |
When a range matches multiple versions, the resolver picks the highest version that satisfies the range at install time. That choice is then recorded in the lockfile, so subsequent installs of the same lockfile produce identical results.
Concrete examples — given the published versions 1.2.3, 1.2.4, 1.3.0, 1.3.5, 2.0.0:
| Range | Matches | Installed (highest) |
|---|---|---|
^1.2.3 | 1.2.3, 1.2.4, 1.3.0, 1.3.5 | 1.3.5 |
~1.2.3 | 1.2.3, 1.2.4 | 1.2.4 |
1.2.3 | 1.2.3 | 1.2.3 |
1.x | 1.2.3, 1.2.4, 1.3.0, 1.3.5 | 1.3.5 |
>=1.3.0 | 1.3.0, 1.3.5, 2.0.0 | 2.0.0 |
^1.0.0 || ^2.0.0 | all of them | 2.0.0 |
1.2.3 - 1.3.0 | 1.2.3, 1.2.4, 1.3.0 | 1.3.0 |
Caret (^) vs tilde (~): which to use when
The two operators look similar but have very different blast radii. Knowing when to reach for each one is the single most useful semver skill.
{
"dependencies": {
"express": "^4.19.2", // accepts 4.19.2 → 4.999.999, refuses 5.0.0
"lodash": "~4.17.21", // accepts 4.17.21 → 4.17.999, refuses 4.18.0
"typescript": "5.5.4" // exact pin: only 5.5.4
}
}Pick caret (^) for runtime libraries that ship to your users at runtime — HTTP clients, validation libraries, UI components. Semver promises minor bumps are backward-compatible, so caret lets you collect bug fixes and new features without manual intervention. The lockfile still pins exactly what you tested against; npm update is what actually pulls new minors.
Pick tilde (~) for libraries that have a history of breaking on minor bumps (some popular ones do — eslint plugins, certain React libraries), or when you want to manually audit every minor upgrade for security or compliance reasons. Tilde still lets patches flow in, which is usually the safest middle ground.
Pick exact pins (no operator) for tools that affect your build output. TypeScript, your bundler, your test runner, your linter — when these bump even a patch, the output of your build can change. Reproducibility matters more than free fixes; pin exactly and upgrade deliberately.
Pre-release versions (-beta.1, -rc.2) and how they're treated by ranges
A pre-release version has a hyphenated suffix after the patch segment:
1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta
1.0.0-beta.2
1.0.0-rc.1
1.0.0
1.0.1-beta.0
1.0.1Pre-release ordering follows two rules: alphanumeric identifiers compare lexicographically; numeric identifiers compare numerically and always sort below alphanumeric ones at the same position. 1.0.0-alpha.10 > 1.0.0-alpha.2 (numeric comparison), and 1.0.0-beta > 1.0.0-alpha.99.
The trap: normal ranges do NOT match pre-releases. If the registry has 2.0.0-rc.1 and 1.5.0 published, the range ^1.0.0 matches 1.5.0 as expected — but the range ^2.0.0 matches nothing, because the only 2.x version is a pre-release and the resolver skips it.
A range matches pre-releases only when the range itself names a version with a pre-release tag on the same MAJOR.MINOR.PATCH triple. So ^1.2.3-beta will accept 1.2.3-beta.4, 1.2.3-rc.1, and 1.2.3 — but not 1.2.4-alpha (different triple) and not 2.0.0-beta (different major). Override this behavior with --include=prerelease (npm CLI) or includePrerelease: true (semver library).
Build metadata (+sha.abc123) is ignored for both precedence and matching: 1.0.0+a and 1.0.0+b are equal for the resolver.
MAJOR 0 special case (^0.x.y)
Semver 2.0.0 section 4 says: "Major version zero (0.y.z) is for initial development. Anything MAY change at any time." npm encodes this directly into the caret operator. When MAJOR is 0, the caret narrows its window to the leftmost non-zero segment.
| Range | Expands to | Why |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0 | Standard: lock MAJOR |
^0.2.3 | >=0.2.3 <0.3.0 | MAJOR is 0, so lock MINOR too |
^0.0.3 | >=0.0.3 <0.0.4 | MAJOR and MINOR are 0, so lock PATCH too — exact match |
~0.2.3 | >=0.2.3 <0.3.0 | Same as caret for 0.x (tilde already locks the minor) |
The practical consequence: a brand-new library at 0.1.0 in your package.json via ^0.1.0 will not auto-upgrade to 0.2.0 when the maintainer ships it. You have to manually bump the range. This is by design — 0.x means the API is unstable, and silently pulling in breaking changes would defeat the point.
When a library hits 1.0.0, its caret range starts behaving the "normal" way and you start getting free minor upgrades. This is often the moment to re-evaluate your dependency lockfile.
Examples: real npm packages and the ranges that match each
The table below shows actual versions published to npm and how popular range syntaxes behave against each. Versions are illustrative as of 2026; check the registry for current state.
| Package | Range in your package.json | What resolves | Notes |
|---|---|---|---|
react | ^18.3.1 | Any 18.x.y | Standard caret on stable major |
react | >=18.0.0 | Any 18.x, 19.x, 20.x... | Common as peerDep range |
zod | ^3.23.8 | 3.23.8 → 3.x.x | Locked at major 3 |
vite | ~5.4.0 | 5.4.x only | Tilde catches minor breakage |
typescript | 5.5.4 | Exactly 5.5.4 | Build tool — pin exactly |
express | ^4.0.0 || ^5.0.0 | 4.x or 5.x | OR range for compat plugins |
@types/node | ^20.14.0 | Any 20.x | Type packages — caret is safe |
my-fork | github:me/fork#abc123 | Git commit abc123 | SHA-pinned; non-registry |
Reproducibility: ranges in package.json vs exact pins in lockfile
A common misconception is that the range in package.json is what gets installed. It is not. The actual installed version is recorded in the lockfile — package-lock.json (npm), pnpm-lock.yaml (pnpm), or yarn.lock (yarn) — and that file is what guarantees reproducibility across machines and CI.
// package.json (the contract)
{
"dependencies": { "lodash": "^4.17.21" }
}
// package-lock.json (the actual install — abbreviated)
{
"packages": {
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}
}
}The split serves two needs. The range expresses what is allowed; the lockfile records what is installed. Running npm install with an existing lockfile installs exactly what the lockfile says, regardless of newer versions matching the range. Running npm update recomputes the lockfile against the current ranges, pulling in newer matches.
Practical rules:
- Always commit the lockfile. Without it, two developers running
npm installon the samepackage.jsoncan end up with different versions of transitive dependencies. - Use
npm ciin CI — it refuses to run ifpackage.jsonand the lockfile disagree, and never modifies the lockfile. - Update deliberately.
npm updatebumps within ranges;npm install pkg@latestmoves a single dependency across the range boundary. - If you need to share a fix before a release, point at a git commit SHA in
package.json— the lockfile will record the SHA and the install will be reproducible.
For deeper background on the package.json contract, see our package.json fields reference.
Key terms
- semver (Semantic Versioning 2.0.0)
- A specification for version numbers in the form MAJOR.MINOR.PATCH, with optional pre-release and build metadata. Defined at semver.org. Used by npm, pnpm, yarn, Cargo, Composer, and many others.
- Range
- A string in
package.jsonthat describes a set of acceptable versions for a dependency. Built from operators (^,~,>=, etc.) combined with semver versions. Parsed by the node-semver library. - Caret operator (
^) - Matches versions compatible with the given one — any change that does not modify the leftmost non-zero segment. For
^1.2.3this means>=1.2.3 <2.0.0; for^0.2.3it means>=0.2.3 <0.3.0. - Tilde operator (
~) - Matches the same MAJOR.MINOR with any PATCH at or above the specified one. For
~1.2.3this is>=1.2.3 <1.3.0. Stricter than caret; useful when a library breaks on minor bumps. - Pre-release
- A version with a hyphenated suffix indicating it is not yet stable:
1.0.0-beta.1,2.0.0-rc.2. Sorts below the corresponding final release. Excluded from normal ranges unless the range itself names a pre-release on the same MAJOR.MINOR.PATCH. - Lockfile
- A separate file (
package-lock.json,pnpm-lock.yaml,yarn.lock) recording the exact versions resolved for every direct and transitive dependency. Commit it for reproducible installs. The range inpackage.jsonis the contract; the lockfile is the actual install.
Frequently asked questions
What is semver?
Semver (Semantic Versioning 2.0.0) is a specification at semver.org that defines version numbers as MAJOR.MINOR.PATCH — three non-negative integers separated by dots. MAJOR is incremented for incompatible API changes, MINOR for backward-compatible new features, and PATCH for backward-compatible bug fixes. Optional suffixes include pre-release tags (1.0.0-beta.1) and build metadata (1.0.0+sha.abc123). npm, pnpm, yarn, Cargo, Composer, and most other package managers follow this spec for their version field and use the npm-style range operators (^, ~, >=, etc.) layered on top to express which versions are acceptable. The whole point is that consumers can read a version bump and predict whether they need to change their code.
What's the difference between ^1.2.3 and ~1.2.3?
Caret (^1.2.3) allows any version compatible with 1.2.3 — that means >=1.2.3 <2.0.0, so any 1.x.y release with x >= 2 and any patch is accepted. Tilde (~1.2.3) is stricter: it allows only patches, so >=1.2.3 <1.3.0. The trade-off is fix coverage vs surprise risk. Caret gets you minor-version bug fixes and new features automatically; tilde gets you only patch fixes and locks the minor. For most application dependencies, caret is the right default because semver promises minor releases are backward-compatible. Use tilde when you have evidence a library breaks its own promise on minor bumps (some popular ones do) or when you need tighter control over auditable transitive changes.
Does ^1.2.3 allow 2.0.0?
No. The caret operator never crosses a MAJOR boundary. ^1.2.3 expands to >=1.2.3 <2.0.0, so version 2.0.0 is explicitly excluded. This is the whole point of semver: MAJOR bumps signal breaking changes, and the caret default protects you from automatically installing a release that may break your code. To accept 2.0.0 you must either change the range to ^2.0.0 (and update your code if needed) or use an explicit cross-major range like >=1.2.3 <3.0.0 (rare and usually a bad idea). One subtle case: ^0.x.y is stricter — see the MAJOR 0 question below.
How does semver treat 0.x versions?
Semver explicitly says anything in the 0.x.y range is unstable and the public API "should not be considered stable." npm encodes this by making the caret operator behave differently when MAJOR is 0. ^0.2.3 expands to >=0.2.3 <0.3.0 — not <1.0.0. In other words, on 0.x versions, a MINOR bump is treated as if it were a MAJOR bump. Going further, ^0.0.3 expands to >=0.0.3 <0.0.4 — only that exact patch. The rule: when MAJOR is 0, the leftmost non-zero segment is the "stability boundary." This is why pre-1.0 libraries can ship breaking changes on minor bumps without violating semver — and why your caret range is much tighter than you might expect.
What's a pre-release version?
A pre-release version is a semver release with a hyphenated suffix: 1.0.0-beta.1, 2.3.0-rc.2, 1.0.0-alpha.7+build.10. Pre-release tags signal that a version is not yet stable. Critically, npm semver does NOT include pre-releases in normal ranges. ^1.2.3 will not match 2.0.0-beta.1 even though the version string sorts higher — pre-releases only match when the range itself specifies a pre-release on the same MAJOR.MINOR.PATCH triple (e.g., ^1.2.3-beta will accept other 1.2.3 pre-releases and 1.2.3 final, but not 1.2.4-alpha). Pre-release ordering: alpha < beta < rc, and numeric identifiers compare numerically. Use --include=prerelease (npm) or includePrerelease (semver library) to override.
Why does ^0.2.3 not allow 0.3.0?
Because semver treats versions where MAJOR is 0 as unstable, the caret operator narrows its window to the leftmost non-zero segment. For ^0.2.3, that segment is MINOR (2), so the range becomes >=0.2.3 <0.3.0 — it accepts patches like 0.2.4 and 0.2.99 but refuses any minor bump. The reasoning: a 0.x library has not yet committed to a stable API, so the maintainer is allowed to make breaking changes on minor bumps. If caret behaved the same as on >=1.x versions, you would auto-upgrade across breaking changes every time a library released a new minor. The same logic applies to ^0.0.3 which means exactly 0.0.3 (and is essentially a pin).
Should I use ^, ~, or exact versions in package.json?
Default to caret (^) for application dependencies — it follows semver's compatibility promise and gives you free patch + minor bug fixes without breakage. Reach for tilde (~) when you have a specific library with a history of breaking on minor bumps, or when you want tighter audit control on a security-critical transitive tree. Use exact pins (1.2.3, no operator) for tooling where reproducibility outweighs bug fixes — e.g., your bundler, your TypeScript version, anything that affects build output. Always commit a lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock). The lockfile is what actually pins your install — the range in package.json is the contract for the next "npm update."
How do I lock a specific commit instead of a version?
You can reference a git commit, branch, or tag directly in package.json by using a git URL instead of a semver range: "my-pkg": "github:user/repo#sha-or-tag". Examples: "lodash": "lodash/lodash#4.17.21" (tag), "my-fork": "git+https://github.com/me/repo.git#abc123def" (commit SHA), "express": "expressjs/express#master" (branch — not recommended; non-reproducible). Commit SHAs are the only fully reproducible form. The package manager will clone the repo and use it as a dependency. Note this skips npm registry entirely, so the package must have a valid package.json and (if it needs a build step) a prepare script. Most teams reserve this for emergency forks or internal packages not published to a registry.
Further reading and primary sources
- Semantic Versioning 2.0.0 — The official semver specification
- npm semver calculator — Interactive tool: paste a range, see which published versions match
- pnpm — versioning and dependency resolution — pnpm docs on dependency fields and range behavior
- node-semver library — The reference implementation npm uses to parse and match ranges
- semver-check — The npm package for programmatic range testing in Node.js