Skip to main content

Semantic Versioning Explained: How Semver Numbers Actually Compare

What major.minor.patch mean, how versions compare field by field, why pre-release tags rank lower, and how caret and tilde ranges decide dependency resolution.

Published By Li Lei
#semantic versioning #semver #dependency management #version comparison #developer tools

Semantic Versioning Explained: How Semver Numbers Actually Compare

Every package you install carries a version string, and most of the time you ignore it until two of those strings sit next to each other and you have to decide which one is newer. That is when semantic versioning stops being a label and starts being a set of rules you either know or guess at. Guessing is where bugs come from, because the obvious answer is often wrong. The string 1.2.10 looks smaller than 1.2.9 if you read it like a word, but it is the newer release. Getting this right matters for changelogs, release gates, and the dependency resolver that decides what actually lands in your node_modules.

This post walks through what each part of a semver number means, how a correct comparison runs field by field, what happens with pre-release tags and build metadata, and how the caret and tilde range operators turn a single version into a window of acceptable upgrades.

What major.minor.patch Actually Mean

A core semver version is three numbers joined by dots: MAJOR.MINOR.PATCH. Each one signals a different kind of change, and the contract is what makes the scheme useful.

  • MAJOR goes up when you make a breaking change. Code written against the old version may stop working. Going from 2.4.1 to 3.0.0 is permission to break things.
  • MINOR goes up when you add functionality in a backward-compatible way. New features, nothing removed. 2.4.1 to 2.5.0.
  • PATCH goes up for backward-compatible bug fixes only. No new features, no breaking changes. 2.4.1 to 2.4.2.

When a left field increments, every field to its right resets to zero. A minor bump from 2.4.7 produces 2.5.0, not 2.5.7. That reset is part of why the numbers compose into a clean ordering rather than three independent counters.

The promise behind these rules is what lets a dependency resolver do its job without asking you. If a library publishes 1.4.0 and your project asked for "anything compatible with 1.2.0", the resolver can take 1.4.0 automatically because minor bumps are, by contract, safe.

How Two Versions Compare, Field by Field

Comparison is not a string operation. It runs left to right, one numeric field at a time, and stops at the first field that differs.

  1. Compare MAJOR as an integer. If they differ, you have your answer.
  2. If majors are equal, compare MINOR as an integer.
  3. If minors are equal too, compare PATCH as an integer.

The word integer is the whole game. A naive sort compares text character by character, so it reads the patch field of 1.2.10 as the characters 1 then 0, sees that 1 sorts before 9, and declares 1.2.9 the winner. That is backwards. Parse the fields as numbers and 10 > 9, so 1.2.10 is correctly newer. The semver compare tool does exactly this and highlights the first segment that differs, so you can see where the decision was made rather than just the verdict.

Pre-release Tags Rank Below the Release

A version can carry a pre-release tag after a hyphen: 1.0.0-alpha, 1.0.0-beta.2, 1.0.0-rc.1. These mark builds that lead up to a release but are not the release yet. The spec gives them lower precedence than the plain version they precede.

So 1.0.0-alpha is older than 1.0.0. So is 1.0.0-rc.1. The bare 1.0.0 is the finished product; everything with a tag dangling off it came before. This trips people up constantly, because intuitively a longer, more decorated string feels like it should be "more". It is the opposite.

Inside the pre-release tag, identifiers are compared left to right between the dots:

  • Numeric identifiers compare numerically. 1.0.0-alpha.1 is older than 1.0.0-alpha.2.
  • Alphanumeric identifiers compare in ASCII order. alpha comes before beta comes before rc.
  • A numeric identifier always ranks below an alphanumeric one. So 1.0.0-1 is older than 1.0.0-alpha.
  • A version with fewer pre-release fields ranks below one with more, when all the earlier fields are equal. 1.0.0-alpha is older than 1.0.0-alpha.1.

A Worked Example

Take two pairs and run them through the rules by hand.

1.2.10 versus 1.2.9. Major: 1 equals 1, move on. Minor: 2 equals 2, move on. Patch: 10 versus 9, compared as integers, 10 > 9. Result: 1.2.10 is the newer version. A string sort would have answered 1.2.9, and it would have been wrong.

1.0.0-beta versus 1.0.0. Major, minor, and patch are all identical: 1.0.0 on both sides. Now the tie-breaker kicks in. The left side has a pre-release tag (beta); the right side has none. A version with a pre-release tag ranks below the one without. Result: 1.0.0-beta is older. The plain 1.0.0 is the real release; 1.0.0-beta was a step on the way there.

Build metadata, by the way, plays no part in either comparison. A trailing +something (such as 1.0.0+build.42) is parsed and displayed but ignored when ordering. 1.0.0+build.1 and 1.0.0+build.999 are equal versions. If you need a build difference to affect ordering, it has to live in the pre-release tag, not the metadata.

I learned this the awkward way on a release script I wrote a couple of years back. It picked the "highest" tag with a sort that treated versions as plain strings, and for months nobody noticed because every release happened to be single-digit patches. Then a hotfix branch crossed x.x.10, the script happily promoted x.x.9 as the latest, and the deploy shipped a build that was missing the fix it was supposed to carry. Ten minutes of staring at the comparison, field by field, would have caught it. Now I check the boundary cases against a real comparator before I trust any version logic at all.

Caret and Tilde: Turning One Version Into a Range

A dependency manifest rarely pins an exact version. Instead it declares a range, and the two range operators you meet most are the caret (^) and the tilde (~). They decide how far a resolver is allowed to wander when it installs an update.

Caret (^) allows any newer version that keeps the same left-most non-zero number. For ^1.2.3, the major 1 is frozen, but minor and patch can rise freely: 1.2.9 and 1.9.0 both satisfy it, while 2.0.0 does not. The reasoning maps directly onto the meaning of the fields — minor and patch bumps are backward-compatible, so they are safe to take automatically, but a major bump might break you, so the caret stops there.

There is one wrinkle worth memorizing. When the major is 0, the leftmost non-zero number is the minor, so the caret pins the minor instead. ^0.2.3 allows 0.2.9 but rejects 0.3.0. Pre-1.0.0 software is treated as unstable, so even minor bumps are considered potentially breaking.

Tilde (~) is tighter. It allows patch-level changes only. For ~1.2.3, the major and minor are frozen: 1.2.9 passes, but 1.3.0 fails. Reach for the tilde when you want bug fixes but no new features at all.

You can check any of this directly: type a version into the semver compare tool range box and feed it ^1.2.3 or ~1.2.3 to see pass or fail. When the input itself is messy — extra whitespace, a stray v prefix, inconsistent formatting — clean it up first with the semantic version normalizer so the comparison runs against canonical strings.

Why Ordering Matters for Dependency Resolution

Put the pieces together and you see why a correct comparison is not a nicety. A resolver builds your dependency tree by, over and over, taking the set of versions that satisfy each declared range and choosing the highest one. Every one of those "highest" decisions is a field-by-field semver comparison. Get the comparison wrong — sort as strings, treat a pre-release as newer than its release, let build metadata tip the scale — and the resolver installs the wrong artifact while reporting success. Nothing errors out. You just ship code you did not mean to ship.

The same logic governs your changelog order, your "is this update safe" check, and any CI gate that only promotes newer builds. When a package-lock.json looks suspicious, the package lock dependency auditor can walk the resolved tree, and feeding individual version pairs through a faithful comparator is how you confirm each ordering decision by hand.

Semantic versioning is a small spec, but it is precise on purpose. Compare field by field as integers, remember that a pre-release ranks below its release, ignore build metadata, and know that ^ allows minor-and-patch while ~ allows patch only. Those four rules cover almost every version question you will ever hit.


Made by Toolora · Updated 2026-06-13