Semantic versioning — what 1.4.2 actually means

You've typed npm install a thousand times and watched the caret in "react": "^18.2.0" quietly do its thing. But what is that caret actually betting on? Version numbers look like arbitrary bookkeeping — until a "minor" update breaks your build and you realize the numbers were a contract all along.

Semantic versioning is that contract: three numbers with precise meanings, agreed on across nearly every ecosystem. Once you know them, you can look at any version — 1.4.2, 0.9.0, 2.0.0-rc.1 — and know exactly what the author is promising.

In this post
  • What MAJOR, MINOR, and PATCH each promise — in one sentence each
  • The 0.x.y "anything goes" phase, and what 1.0.0 means
  • Pre-releases, build metadata, and how versions actually sort
  • What the ^ and ~ in package.json are betting on
  • How Conventional Commits turn the whole thing into automation

The three numbers

Semantic versioning is MAJOR.MINOR.PATCH — three numbers, three promises:

the-shape.txt
1 . 4 . 2
│   │   │
│   │   └─ PATCH — backwards-compatible bug fixes
│   └───── MINOR  — new features, backwards-compatible
└───────── MAJOR  — breaking changes

Stripped to one sentence each:

  • PATCH — "I fixed bugs. Nothing else changed. Upgrading is always safe."
  • MINOR — "I added something. Nothing you're already using broke. Upgrading is safe."
  • MAJOR — "Something you might be using changed or went away. Read the changelog first."

And when you bump a number, everything to its right resets to zero — because the lower numbers only have meaning within the level above them:

bumping.txt
1.4.2  bug fix       1.4.3
1.4.3  new feature   1.5.0   patch resets
1.5.0  breaking API  2.0.0   minor + patch reset

The 0.x.y phase — anything goes

Everything above applies once you've released 1.0.0. Before that, in 0.x.y territory, the rules are different: the API is not stable, and anything may change at any time — including breaking changes in a minor bump.

zero-phase.txt
0.2.0  0.3.0   this CAN break things — minor bumps in 0.x are allowed to
0.9.9  1.0.0   "we promise the public API is stable now"
What 1.0.0 actually means

Shipping 1.0.0 isn't a feature milestone — it's a promise. It says "I now own the consequences of changing this API." That's why serious libraries stay at 0.x for a long time: they're not ready to make that promise yet.

Pre-releases and build metadata

After the three numbers, two optional suffixes can appear — a hyphen for pre-releases, a plus for build metadata:

suffixes.txt
# pre-releases sort BEFORE the plain version
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0

# build metadata is IGNORED for ordering
1.0.0+build.123  ==  1.0.0+build.456   same precedence

The rule that trips people up: 1.0.0-rc.1 is older than 1.0.0. A pre-release is a step toward the version, not past it. And build metadata — commit hashes, build numbers — is for humans and CI; the version sorter pretends it isn't there.

What the ^ and ~ are betting on

This is where semver stops being theory and starts affecting your npm install on Monday morning. The range operators in package.json are bets that the library author follows semver honestly:

package.json
"^1.2.3"   >=1.2.3 <2.0.0  — trust minor + patch ("won't break me")
"~1.2.3"   >=1.2.3 <1.3.0  — trust patch only ("bug fixes only")
"1.2.3"    exactly 1.2.3   — trust nothing
"*"        anything        — trust everything (brave)

The caret — npm's default — says "give me everything short of a major bump." That's only safe if the author reserves major bumps for genuine breaking changes. If they sneak a breaking change into a minor release, your ^ pulls it in automatically and your build breaks on an install that was supposed to be routine. Semver only works because most authors keep the promise.

The cardinal sin

Breaking backwards compatibility in a minor bump is the one unforgivable move — it breaks every downstream ^ range silently. If your change breaks anyone, it's a major bump, full stop. "But it's a small change" is irrelevant: size doesn't matter, compatibility does.

The automation: Conventional Commits

Here's the payoff of the whole system. If your commit messages follow a convention, the version bump becomes derivable from your git history — no human deciding whether it's a minor or a patch:

the-mapping.txt
fix: correct off-by-one in pagination       1.4.2 → 1.4.3  patch
feat: add CSV export                        1.4.3 → 1.5.0  minor
feat!: drop support for Node 16             1.5.0 → 2.0.0  major
BREAKING CHANGE: (in the footer)                        major

Tools like semantic-release and standard-version read this mapping and cut releases, changelogs, and version bumps automatically. That's the real reason Conventional Commits exist — not tidiness for its own sake, but so versioning stops being a debate. The commit message generator drafts the format in seconds, including the BREAKING CHANGE footer when you toggle it.

The mistakes to avoid

  • Bumping patch for a breaking change because "it's small." Compatibility, not size, decides the number.
  • Bumping major for everything to be "safe." Semver fatigue makes your ^ meaningless and trains users to ignore your releases.
  • Confusing a big rewrite with a major version. A ground-up rewrite that keeps the same public API is not a major bump — your users can't tell the difference, and that's the point.
  • Staying at 0.x forever to avoid responsibility. Ship 1.0.0 when the API settles; perpetual 0.x just pushes the stability work onto every consumer.

The full spec is short and worth one read: semver.org covers every edge case this post glides over.

Wrap-up

MAJOR breaks, MINOR adds, PATCH fixes. 0.x means anything goes, 1.0.0 is a promise, and a pre-release sorts before the version it's aiming at. The caret in your package.json is a bet on other people keeping that promise — and Conventional Commits is how you keep it automatically on your own projects.

If your team's arguing about whether a change is "big enough" for a major bump, you're asking the wrong question — ask whether it breaks anyone. And if your history needs the discipline that makes versioning derivable, the rebase-vs-merge post keeps that history clean enough to read in the first place.