All notes
SemverAugust 8, 20266 min read

^ versus ~: what those prefixes actually mean

Two characters your package.json uses every day. Understanding what they permit and what they prevent is the difference between a safe upgrade and a silent breakage.

Every dependency line in a package.json is a quiet contract between you and the package author. The version string says "I need at least this level of functionality," and the prefix—^ or ~—defines how far the package manager is allowed to move that version on a fresh install. Pick the wrong one and you either absorb breaking changes you did not expect or leave security fixes on the table.

What semver gives us

Semantic Versioning 2.0.0, published by Tom Preston-Werner in 2013 after several years of iteration inside GitHub and npm, specifies a three-part version number MAJOR.MINOR.PATCH. Increment the MAJOR version for incompatible API changes, the MINOR version for backward-compatible new functionality, and the PATCH version for backward-compatible bug fixes. The version 1.4.2 means major 1, minor 4, patch 2. Before 1.0.0 (major version zero) the spec explicitly warns that anything may change at any time, and the public API should not be considered stable.

npm adopted semver from its earliest days. The node-semver library is bundled with npm and parses the ranges used by its installer. Every range expression you write is desugared into a set of primitive comparators.

The tilde: ~

The tilde tells npm to allow patch-level changes while locking the specified major and minor versions:

  • ~1.2.3>=1.2.3 <1.3.0
  • ~1.2>=1.2.0 <1.3.0 (same as 1.2.x)
  • ~1>=1.0.0 <2.0.0 (same as 1.x)
  • ~0.2.3>=0.2.3 <0.3.0
  • ~0>=0.0.0 <1.0.0

The pattern is consistent: tilde constrains the next significant position after whatever you specify. If you specify three positions, only the patch can move. If you specify one position, both minor and patch can move but major stays locked. This was npm’s default --save prefix in npm v2 (circa 2014), reflecting a conservative philosophy: only accept the safest possible updates automatically.

The caret: ^

The caret allows changes that do not modify the leftmost non-zero element in the [major, minor, patch] tuple:

  • ^1.2.3>=1.2.3 <2.0.0
  • ^0.2.3>=0.2.3 <0.3.0 (minor is leftmost non-zero)
  • ^0.0.3>=0.0.3 <0.0.4 (patch is leftmost non-zero)
  • ^0.x>=0.0.0 <1.0.0

This behavior is the reason caret is more permissive for stable packages (major ≥ 1) and more restrictive for early-stage ones: ^1.2.3 accepts minor releases but ^0.2.3 treats minor as a breaking boundary. That mirrors how most maintainers treat 0.x versions in practice:0.3.0 often contains breaking changes relative to 0.2.x.

In 2015 npm v3 changed the default --save prefix from tilde to caret, reflecting the maturing ecosystem. More packages had reached 1.0.0, and the community had collectively decided that semver-compliant minor releases were a safe bet for automatic adoption. The save-prefix config still lets you switch back to ~ or opt into exact pins with --save-exact.

Prereleases and ranges

Prerelease tags such as -alpha.1, -beta, or -rc.2 have a special interaction with range matching. By default, a prerelease version is only matched by a range if the range itself includes a prerelease tag on the same [major, minor, patch] tuple:

  • ^1.2.3-beta.2 matches 1.2.3-beta.4 but not 1.2.4-beta.2
  • >=1.2.3-beta.2 matches 1.2.3-beta.4 but not 3.0.0-alpha.1

This prevents accidental adoption of unstable pre-releases. If you write ^1.2.3 (without a prerelease tag), you will never receive 1.2.4-alpha.0 even though its tuple is within range. You opt into prereleases explicitly by tagging them in your range. The includePrerelease flag in node-semver suppresses this behavior, but npm does not set it by default.

A range is a maintenance policy

A semver range is not a safety guarantee. It is a policy decision about how much change you are willing to accept without manual review. The spec is only as reliable as the humans publishing packages. Accidental semver violations, omitted deprecation warnings, and major bumps hidden inside minor releases happen regularly in the real world.

A lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml) is what actually pins your installed versions. The range in package.json defines the boundary for npm install (when no lockfile exists), for npm update, and for fresh installs in CI. If you run npm update on a project with ^1.2.3, you get 1.x updates; with ~1.2.3, you get only 1.2.x updates. The lockfile records exactly what was resolved so that two installs at different times remain reproducible.

A range declares intent. A lockfile records fact. Both matter.

A practical recommendation framework

There is no single correct prefix for every dependency. The right choice depends on the package stability, your project velocity, and your tolerance for CI noise. Here is a framework that works across teams:

  • Stable libraries at 1.x or above: Use ^. Accept minor and patch updates automatically. Review major updates separately.
  • CLI tools and dev dependencies: Use ^ or ~ depending on your cadence. If the tool is updated infrequently and each change is risky, ~ is safer.
  • Packages with weak release discipline: Use ~ or an exact pin when you want a narrower review window. Do not let a package with unpredictable releases surprise you during a routine install.
  • Major version zero packages: Use ~ or pin exact. The spec says anything may break, and ^0.2.3 already limits to the same minor range (0.2.x), but ~ makes the intent clearer to readers.
  • Peer dependencies: Use ^ with a generous lower bound. Peer ranges that are too narrow cause install failures; ranges that are too wide risk incompatibility. A range like ^1.4.0 || ^2.0.0 lets consumers pick their major.
  • In CI or production deployments: Commit the lockfile and use npm ci (or equivalent). The range only matters when you intentionally upgrade.

Whichever prefix you choose, be explicit about it in your project conventions. A .npmrc with save-prefix="~" is better than a team where half the developers use one default and half use the other.

tl;dr

^ says "compatible with this version" and is the npm default since v3. ~ says "approximately this version" and locks the minor. Both are correct in different contexts. Understand the difference, choose deliberately, and let the lockfile do the exact pinning.

Sources and further reading

Keep reading

A safer way to update every package in package.json.

Read the guide