research/comparing-version-strings-without-a-library

Comparing Version Strings Without a Library: A 30-Line Semver Compare, Fuzzed Against the Reference

Engineering Note · November 4, 2026 · 7 minute read · Node.js · Testing · Versions

Yes, you can compare semantic version strings correctly in about 30 lines of JavaScript, as long as you handle the prerelease rules and compare numbers as numbers. Our version agreed with the semver npm package on all 200,000 random pairs we tried. It disagreed with the package on five hand-picked edge cases: on two the package gave up where ours answered correctly, on two ours is too lenient, and on one ours is too strict.

Everything below was run with Node.js 22.23.4 on Linux and semver 7.8.6. The code was run as shown, and the outputs are the real ones.

What are we comparing?

We use the rules in Semantic Versioning 2.0.0. A version is MAJOR.MINOR.PATCH, optionally followed by a -prerelease part and a +build part.

  • The three core numbers compare as numbers, left to right.
  • A version with a prerelease part ranks below the same core without one.
  • Prerelease parts split on dots and compare piece by piece. Number pieces compare as numbers, text pieces compare as text, number pieces rank below text pieces, and a shorter list ranks below a longer one that starts the same way.
  • Build metadata after + is ignored.

What does the code look like?

const RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;

export function parse(v) {
  const m = RE.exec(v);
  if (!m) throw new TypeError(`not a version: ${JSON.stringify(v)}`);
  return { core: [BigInt(m[1]), BigInt(m[2]), BigInt(m[3])], pre: m[4] ? m[4].split('.') : [] };
}

const isNum = (s) => /^\d+$/.test(s);

function comparePre(a, b) {
  if (!a.length && !b.length) return 0;
  if (!a.length) return 1;   // a release outranks any of its prereleases
  if (!b.length) return -1;
  for (let i = 0; i < Math.min(a.length, b.length); i++) {
    const x = a[i], y = b[i];
    if (x === y) continue;
    if (isNum(x) && isNum(y)) return BigInt(x) < BigInt(y) ? -1 : 1;
    if (isNum(x)) return -1;  // numeric ids sort before text ids
    if (isNum(y)) return 1;
    return x < y ? -1 : 1;    // text ids compare in ASCII order
  }
  return a.length === b.length ? 0 : a.length < b.length ? -1 : 1;
}

export function compare(a, b) {
  const x = parse(a), y = parse(b);
  for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1;
  return comparePre(x.pre, y.pre);
}

The file is 30 lines. It returns -1, 0 or 1, so it works directly with Array.prototype.sort. We use BigInt for the numbers so that a very long number does not lose precision. We will come back to why that matters.

Where does string comparison go wrong?

We ran six pairs through three comparers: the JavaScript < and > operators on the raw strings, our function, and semver.compare.

1.9.0           1.10.0          string:  1  ours: -1  semver: -1
1.0.0-rc.1      1.0.0           string:  1  ours: -1  semver: -1
1.0.0-alpha.2   1.0.0-alpha.10  string:  1  ours: -1  semver: -1
1.0.0-alpha     1.0.0-alpha.1   string: -1  ours: -1  semver: -1
1.0.0-1         1.0.0-alpha     string: -1  ours: -1  semver: -1
2.0.0+build.5   2.0.0+build.1   string:  1  ours:  0  semver:  0

String comparison got four of the six wrong. It put 1.9.0 above 1.10.0, ranked a release candidate above its own final release, put alpha.2 above alpha.10, and treated two builds of the same version as different. Two of the six, the fourth and fifth, it got right by luck of character order.

Our function matched the package on every row, including the last one, where build metadata is ignored and the result is 0.

We also checked the ordering example from the semver specification. Eight versions, from 1.0.0-alpha up to 1.0.0, came out strictly ascending (spec chain ascending: true). A short sort gave a sensible result:

[ '1.2.0-rc.1', '1.2.0', '1.9.1', '1.10.0' ]

How did we fuzz it?

Fuzzing means throwing a large volume of generated input at the code and checking it against something you trust. The trusted thing here is the semver package, which the npm ecosystem relies on.

Our generator builds each version from awkward ingredients. Core numbers come from 0, 1, 2, 9, 10, 11, 100 and 12345678901. Prerelease pieces come from a pool that includes alpha, beta, rc, Alpha (capitalized, to test ASCII ordering), a-b, x-1, 0a, 00a, 1e3 and 18446744073709551616, a number larger than 2 to the 64th power. A third of versions get build metadata. The random generator uses a fixed seed, so the run repeats exactly.

let seed = 20261104;
const rnd = (n) => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed % n; };
// ... generate a, b; skip any pair semver says is invalid ...
const ours = compare(a, b), ref = semver.compare(a, b);
if (ours !== ref) bad.push([a, b, ours, ref]);

The result:

{ generated: 200000, validPairs: 200000, mismatches: 0, ms: 1586 }
[]

All 200,000 pairs were valid versions, and none produced a different answer. The whole run took about a second and a half.

That is a good result, but a fuzzer only finds what its generator can produce. Ours never makes leading zeros such as 01.2.3, and it rarely produces two huge numbers that differ only in the last digit. So we tested those by hand.

What do the edge cases show?

1.0.0-18446744073709551616 vs 1.0.0-18446744073709551617 | ours: -1 | semver: 0
9007199254740993.0.0 vs 9007199254740992.0.0 | ours: 1 | semver: throws: Invalid major version
01.2.3 vs 1.2.3 | ours: 0 | semver: throws: Invalid Version: 01.2.3
1.0.0-01 vs 1.0.0-1 | ours: 1 | semver: throws: Invalid Version: 1.0.0-01
v1.2.3 vs 1.2.3 | ours: throws | semver: 0
1.2 vs 1.2.0 | ours: throws | semver: throws: Invalid Version: 1.2

Reading these line by line:

  1. Two prerelease numbers beyond 64 bits. Ours says the second is larger, which is right. The package says they are equal. JavaScript numbers lose exact precision above 9,007,199,254,740,991, so two neighbors become the same number. This is why we used BigInt.
  2. A core number above the safe range. The package refuses it with “Invalid major version”. Ours compares it exactly. (Our first draft used ordinary numbers for the core and returned 0 here, which was wrong. The BigInt change fixed it, and the fuzz run above was done after that change.)
  3. A leading zero. The semver specification forbids 01 in a core number. The package throws. Ours accepts it and calls the versions equal.
  4. A leading zero in a numeric prerelease piece. Same story: the specification forbids it, the package throws, and ours accepts it.
  5. A v prefix. The package tolerates v1.2.3. Ours throws, because the pattern is strict.
  6. A short version. Both throw on 1.2.

Cases 3 and 4 are real gaps in ours if your input is untrusted. A one-line fix would reject numbers with leading zeros in the pattern. Whether to be strict or forgiving is a product decision. For sorting versions your own build tooling produced, strict and fast is fine. For input typed by users, you will want to decide what v1.2 means before you accept it.

When is this worth doing yourself?

Writing the compare makes sense when:

  • you only need to order strict versions, for example sorting release folders or picking the newest tag in a script,
  • you want no dependency in a small tool or an edge function, or
  • you want to understand the rules well enough to read other people’s bug reports about them.

Use the package when you need ranges (^1.2.0), loose parsing, or version increments. Those features carry their own edge cases, and the package already has years of bug reports behind it.

Limitations

  • We tested against one reference implementation, semver 7.8.6. Agreement with it shows we match it, not that either is perfect.
  • The generator is small and seeded. It covers the features listed above, not every possible string. It never produces leading zeros, v prefixes or short versions, which is why those were tested by hand.
  • Our code is strict about format but does not reject leading zeros. Treat it as a comparer for trusted input.
  • We did not benchmark speed. The 1.6 seconds for 200,000 pairs includes the package calls, the generator and the validity checks, so it says nothing about either compare function alone.
  • Other ecosystems use other rules. Python’s PEP 440 versions, Debian package versions and Ruby gem versions order things differently from semver, and this code does not handle them.

Frequently asked questions

Why can't I compare version strings with a plain less-than?

String comparison goes character by character, so "1.10.0" sorts before "1.9.0" because "1" comes before "9". In our run, string comparison also got "1.0.0-rc.1" versus "1.0.0" wrong, ranking the prerelease higher. Versions need to be split and compared by part.

What are the semver prerelease rules in short?

A version with a prerelease tag, such as 1.0.0-rc.1, ranks below the same version without one. Prerelease tags are compared one dot-separated piece at a time. Numeric pieces compare as numbers, text pieces compare as text, a numeric piece ranks below a text piece, and a shorter list ranks below a longer one that starts the same way.

Does build metadata affect version precedence?

No. The part after a plus sign, as in 2.0.0+build.5, is ignored when ranking versions. In our test, 2.0.0+build.5 and 2.0.0+build.1 compared as equal, matching the semver package, even though plain string comparison ranked them differently.

How do you test a version comparison function?

Generate many random valid versions, run the same pair through your function and a trusted reference, and fail on any disagreement. We used 200,000 pairs built from awkward pieces such as 0, 9, 10, 11, text tags and numbers beyond safe integer range. Then add a short list of hand-picked edge cases the generator would rarely hit.

When should you use the semver package instead?

Use it when you need ranges such as ^1.2.0, loose parsing of strings like v1.2 or incrementing versions. A 30-line compare covers one job: ordering strict versions. If your inputs are messy or you need range logic, the package already handles cases you would otherwise rebuild and re-test.

More from Research

← All research