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.
- What MAJOR, MINOR, and PATCH each promise — in one sentence each
- The
0.x.y"anything goes" phase, and what1.0.0means - 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:
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:
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.
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"
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:
# 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:
"^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.
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:
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.xforever to avoid responsibility. Ship1.0.0when the API settles; perpetual0.xjust 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.