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.

Validate package.json

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:

SegmentBumped whenConsumer impactExample
MAJORIncompatible API changeYou may need to update your code1.4.22.0.0
MINORBackward-compatible new featureSafe to install; new functionality available1.4.21.5.0
PATCHBackward-compatible bug fixSafe to install; no API changes1.4.21.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.

OperatorExampleExpands toUse when
Caret ^^1.2.3>=1.2.3 <2.0.0Default for runtime libraries
Tilde ~~1.2.3>=1.2.3 <1.3.0Patches only; lock the minor
Exact1.2.3=1.2.3Build tools; reproducibility-critical
Wildcard * / x1.2.x or 1.x or *Any value at that segmentAlmost never; * is dangerous
Comparator>=1.2.3 <2.0.0Itself (literal)Explicit cross-version ranges
Hyphen -1.2.3 - 1.5.0>=1.2.3 <=1.5.0Inclusive on both ends
OR ||^1.0.0 || ^2.0.0Either range matchesPeer deps spanning majors
Latest taglatestWhatever the registry tags as latestAvoid in committed package.json
Git URLgithub:user/repo#shaClone at that refFork 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:

RangeMatchesInstalled (highest)
^1.2.31.2.3, 1.2.4, 1.3.0, 1.3.51.3.5
~1.2.31.2.3, 1.2.41.2.4
1.2.31.2.31.2.3
1.x1.2.3, 1.2.4, 1.3.0, 1.3.51.3.5
>=1.3.01.3.0, 1.3.5, 2.0.02.0.0
^1.0.0 || ^2.0.0all of them2.0.0
1.2.3 - 1.3.01.2.3, 1.2.4, 1.3.01.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.1

Pre-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.

RangeExpands toWhy
^1.2.3>=1.2.3 <2.0.0Standard: lock MAJOR
^0.2.3>=0.2.3 <0.3.0MAJOR is 0, so lock MINOR too
^0.0.3>=0.0.3 <0.0.4MAJOR and MINOR are 0, so lock PATCH too — exact match
~0.2.3>=0.2.3 <0.3.0Same 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.

PackageRange in your package.jsonWhat resolvesNotes
react^18.3.1Any 18.x.yStandard caret on stable major
react>=18.0.0Any 18.x, 19.x, 20.x...Common as peerDep range
zod^3.23.83.23.8 → 3.x.xLocked at major 3
vite~5.4.05.4.x onlyTilde catches minor breakage
typescript5.5.4Exactly 5.5.4Build tool — pin exactly
express^4.0.0 || ^5.0.04.x or 5.xOR range for compat plugins
@types/node^20.14.0Any 20.xType packages — caret is safe
my-forkgithub:me/fork#abc123Git commit abc123SHA-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 lockfilepackage-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 install on the same package.json can end up with different versions of transitive dependencies.
  • Use npm ci in CI — it refuses to run if package.json and the lockfile disagree, and never modifies the lockfile.
  • Update deliberately. npm update bumps within ranges; npm install pkg@latest moves 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.json that 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.3 this means >=1.2.3 <2.0.0; for ^0.2.3 it 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.3 this 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 in package.json is 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