All posts

Semantic Versioning: How Versions Bump, Compare and Precedence Work

July 27, 2026 · DevTools

semver
versioning
npm
dependencies
developer-tools

You run npm install and see lodash updated from 4.17.20 to 4.18.0. Both are 4.x. Your tests pass. You deploy. Something breaks. You did not notice that 4.18.0 had a behavioral change in a function you were using. Semantic versioning says 4.18.0 is a minor bump (new functionality, backward-compatible). The library author disagreed with you about what "backward-compatible" means.

Semver exists to give developers enough information to make update decisions without reading every changelog. When it is followed correctly, ^4.17.20 in your package.json means "accept any 4.x.x version that does not break the major version." When it is not followed correctly, that ^ becomes a liability.

The Semantic Version Calculator compares versions, computes ranges, and shows which versions satisfy a given constraint.

The format

major.minor.patch[-prerelease][+build]
  └──────┘└───┘└────┘└─────────────┘└──────┘
   break   add    fix     optional     optional

1.2.3 is the baseline. 1.2.3-alpha.1 is a prerelease. 1.2.3+build.123 has build metadata (ignored for precedence).

Major bumps when there are breaking changes — any change that breaks existing functionality or change the public API.

Minor bumps when adding new functionality in a backward-compatible way.

Patch bumps for backward-compatible bug fixes.

That is the contract. Violating it — adding a feature in a patch release, or breaking API in a minor — breaks the trust that package-lock.json is meant to protect.

Prerelease precedence

Prerelease versions sort before the stable release:

1.0.0-alpha    < 1.0.0-alpha.1    < 1.0.0-beta    < 1.0.0

Numeric identifiers sort by numeric value, not lexicographic. alpha.2 > alpha.10 because 2 < 10.

The rule: when comparing two prerelease versions with the same major.minor.patch, compare each dot-separated identifier left to right. If both are numeric, compare numerically; if both are non-numeric, compare lexicographically. The first difference determines the order.

1.0.0-2    < 1.0.0-10   (numeric: 2 < 10)
1.0.0-a    < 1.0.0-b   (lexicographic: a < b)
1.0.0-2    < 1.0.0-alpha  (numeric vs non-numeric: numeric < non-numeric)

The caret and tilde operators

In package.json, the default versioning strategy in npm and other package managers:

{
  "lodash": "^4.17.21",   // >=4.17.21, <5.0.0
  "axios": "~1.6.0",     // >=1.6.0, <1.7.0
  "express": "4.18.2"     // exactly 4.18.2
}

^ (caret) — the most permissive. Allows changes that do not modify the leftmost non-zero digit. For ^4.17.21, the leftmost non-zero digit is 4, so it allows >=4.17.21, <5.0.0. For ^0.2.3, the leftmost non-zero digit is 2, so it allows >=0.2.3, <0.3.0. ^0.x.x is intentionally conservative.

~ (tilde) — more conservative. Allows patch-level changes. ~1.6.0 allows >=1.6.0, <1.7.0.

x / * — wildcards. * allows any version. 1.x allows any minor version.

^1.2.3  → >=1.2.3, <2.0.0
~1.2.3  → >=1.2.3, <1.3.0
1.x     → >=1.0.0, <2.0.0
1.2.x   → >=1.2.0, <1.3.0

Comparing versions in code

from packaging.version import Version

# Works for all valid semver
assert Version("1.2.3") < Version("1.3.0")
assert Version("1.0.0-alpha") < Version("1.0.0")
assert Version("1.0.0-alpha.1") < Version("1.0.0-alpha.2")
assert Version("1.0.0+build") == Version("1.0.0")  # build metadata ignored
import { satisfies } from "semver";

semver.satisfies("4.18.0", "^4.17.0");  // true
semver.satisfies("5.0.0", "^4.17.0");   // false

What "backward-compatible" actually means

The hard part of semver is the definition of "backward-compatible." A correct interpretation:

  • A function that accepted string | number and now accepts string | number | null is not a breaking change (adding a type is backward-compatible for JavaScript callers)
  • A function that accepted string | number and now accepts only string is a breaking change (removed a type)
  • A function that returns number and now returns string is a breaking change
  • Adding a required parameter is a breaking change
  • Adding an optional parameter is NOT a breaking change
  • Renaming an exported function is a breaking change (unless aliased)
  • Changing default argument values is generally considered a breaking change in strict semver

Many libraries are too loose with minor bumps. lodgeit-labs/assessment_aggregator famously published a breaking change as a minor bump. The response was to introduce BREAKING_CHANGES.md as a counter-document — a signal that the semver contract was broken.

Bumping correctly

Before releasing a new version:

  1. Read the changelog since the last release. What changed?
  2. If anything removed or changed existing behavior — bump major.
  3. If only new behavior was added — bump minor.
  4. If only bugs were fixed — bump patch.
  5. If the change is ambiguous — default to a major bump. It is easier for users to lock to a lower version than to recover from an unexpected breaking change.

Use the Semantic Version Calculator to compare your current version against the proposed new version and see what constraint range the change implies.