Guide + free tool

Semantic versioning, and what npm does with your numbers

Major, minor and patch, what 0.x is allowed to mean, how pre-releases sort, and the ^ and ~ ranges that decide what your users actually install. I ran every npm behaviour on this page through the real semver package and pasted what came back. The spec is short. npm’s reading of it is where the surprises are.

Definition

What semantic versioning is

Semantic versioning, or semver, is a rule for version numbers: three numbers, MAJOR.MINOR.PATCH, where the one you bump tells everyone who depends on your code what kind of change they’re getting. A backward compatible bug fix bumps the patch. A backward compatible feature bumps the minor. Anything that breaks the public API bumps the major. Use it whenever other code depends on yours, so libraries, packages, APIs and CLIs. The spec is at version 2.0.0 and lives at semver.org. It also defines pre-release labels like 1.0.0-beta.1, build metadata like 1.0.0+sha.5114f85, and an exact order for comparing all of it. What the spec can’t do is decide which number to bump. You decide, or your commits do. With Conventional Commits, fix means patch, feat means minor, a breaking change means major, and a release tool reads the history and does the arithmetic.

Free tool

Work out your next version

Paste git log --oneline since your last tag and set your current version. The checker finds the biggest bump in those commits and computes the next version, once the way release-please does it and once the way semantic-release does with no config. Nothing leaves your browser.

Commit checker

5 commits, 1 with errors. Nothing leaves your browser.

release-please

2.0.0 major

A breaking change.

semantic-release, default config

1.5.0 minor

At least one feat. A "!" commit was skipped.

  1. feat(search): find frogs by location

    featscope: searchspec: minor bumpchangelog: Features

    Valid under the spec and commitlint’s default config.

  2. fix: stop the map jumping on zoom

    fixspec: patch bumpchangelog: Bug Fixes

    Valid under the spec and commitlint’s default config.

  3. feat!: drop the v1 frogs endpoint

    featbreakingspec: major bumpchangelog: BREAKING CHANGES

    Valid under the spec and commitlint’s default config.

  4. chore: bump eslint

    chorespec: no version meaningchangelog: Miscellaneous Chores (hidden)

    Valid under the spec and commitlint’s default config.

  5. docs: Fix typo in README.

    docsspec: no version meaningchangelog: Documentation (hidden)

    • commitlint · subject-case commitlint rejects a description that starts with a capital letter. Lower-case the first word.
    • commitlint · subject-full-stop No full stop at the end of the description.

This tells you what the release tools will do with your commits. Writing the post about the release is a separate job, and that one I automated.

The rules

Major, minor or patch

Starting from 1.4.2, this is what each kind of change does. The deprecation row surprises people, but the spec is explicit: marking public API as deprecated is a minor bump, and the removal comes later in a major. The dependency row comes from the spec’s FAQ, which says it depends on whether you updated to fix a bug or to add something.

Change since 1.4.2BumpNext version
A bug fix that changes no APIpatch1.4.3
A new feature that breaks nothingminor1.5.0
Marking part of the public API as deprecatedminor1.5.0
Anything that breaks the public APImajor2.0.0
A dependency update, public API unchangedpatch or minor, depending on why1.4.3 or 1.5.0
  • Bumping the minor resets the patch to 0. Bumping the major resets both. There is no 1.5.3 straight after 1.4.2.
  • Each number is an integer and compares as one, so 1.9.0 is followed by 1.10.0, and semver.gt('1.10.0', '1.9.0') returns true. Sorting version strings alphabetically gets this wrong.
  • A released version never changes. If you shipped a mistake, you ship a new version. If the mistake was a breaking change in a minor release, the FAQ says to release a patch that restores compatibility.
  • The spec starts with a requirement most projects skip: you have to declare a public API, in code or in docs. Without one, “breaking” has no meaning. I’d count anything someone could reasonably have written code against as public, documented or not.

Initial development

What 0.x means

Version zero is for initial development, and the spec gives it one rule: “Anything MAY change at any time” (SemVer 2.0.0, item 4). No bump rules at all. The FAQ suggests starting at 0.1.0 and bumping the minor for each release, and it has a blunt answer for when to leave: “If your software is being used in production, it should probably already be 1.0.0.”

npm fills the gap the spec leaves. A caret range on a 0.x version locks the minor, so ^0.2.3 accepts 0.2.9 but not 0.3.0. In practice that makes the minor the breaking-change number below 1.0.0, and the patch the everything-else number. Release tools disagree about whether to follow that. I ran three commits through standard-version and commit-and-tag-version on a test repo at 0.3.1. The release-please columns follow its documented versioning rules:

Commit on 0.3.1release-please, defaultrelease-please, both pre-major options onstandard-version / commit-and-tag-version
feat!: drop the v1 endpoint1.0.00.4.00.4.0
feat: add frog search0.4.00.3.20.3.2
fix: stop the map jumping0.3.20.3.20.3.2

The two release-please options are bump-minor-pre-major and bump-patch-for-minor-pre-major, both off by default. That default is how a project goes from 0.3.1 to 1.0.0 the first time someone types feat!. If you’re not ready for 1.0.0, turn them on. If you’re in production, the spec’s FAQ would say the tool just did you a favour.

Alpha, beta, rc

Pre-release versions and their order

A pre-release is a hyphen and dot-separated identifiers after the patch: 1.0.0-alpha, 1.0.0-beta.2, 2.0.0-rc.1. Identifiers use ASCII letters, digits and hyphens, can’t be empty, and a purely numeric one can’t have a leading zero. A pre-release sorts below the release it leads up to, so 1.0.0-rc.1 comes before 1.0.0.

Between two pre-releases of the same version, the spec compares identifiers left to right. Numbers compare as numbers, anything with a letter compares in ASCII order, a number always sorts below a word, and if one list is a prefix of the other, the longer one wins. I shuffled the spec’s own example list and ran it through semver.sort. It came back in the spec’s order:

1.0.0-alpha
1.0.0-alpha.1
1.0.0-alpha.beta
1.0.0-beta
1.0.0-beta.2
1.0.0-beta.11
1.0.0-rc.1
1.0.0

The trap is the dot. beta.11 sorts after beta.2 because 11 is its own numeric identifier. Drop the dot and beta11 is a single word compared character by character, so 1.0.0-beta10 sorts before 1.0.0-beta2. I checked. Always put a dot before the counter.

For bumping, npm version prerelease --preid beta took a test package from 1.4.3 to 1.4.4-beta.0, then 1.4.4-beta.1. The semver package does the same thing, and the last two rows here are the ones to know: bumping a pre-release finishes it rather than skipping past it.

semver callResult
inc('1.2.3', 'prerelease', 'beta')1.2.4-beta.0
inc('1.2.4-beta.0', 'prerelease')1.2.4-beta.1
inc('1.2.3', 'preminor', 'beta')1.3.0-beta.0
inc('1.2.3', 'premajor', 'rc')2.0.0-rc.0
inc('1.2.4-beta.0', 'patch')1.2.4
inc('1.3.0-beta.1', 'minor')1.3.0

The plus sign

Build metadata

Build metadata goes after a +, following the patch or the pre-release: 1.0.0+20130313144700, 1.0.0-beta+exp.sha.5114f85. The spec’s rule is short: “Build metadata MUST be ignored when determining version precedence.” Two versions that differ only after the plus are the same version as far as ordering goes.

The semver package agrees. compare('1.0.0+build.1', '1.0.0+build.2') returns 0, and eq('1.0.0+a', '1.0.0') is true. If you need a tie-breaker anyway, compareBuild returns -1 for the same pair. parse splits it out cleanly, so 1.0.0-beta+exp.sha.5114f85 gives a pre-release of ['beta'] and a build of ['exp', 'sha', '5114f85']. And inc('1.2.3+build.7', 'patch') gives 1.2.4, metadata gone. Use it for information, like the commit a binary came from. Don’t use it to tell two releases apart.

Where semver meets your users

Caret and tilde ranges in npm

npm install saves a caret range by default. On npm 11.11.0, npm config get save-prefix prints ^, and installing semver wrote "semver": "^7.8.5" into package.json. With --save-exact it wrote "7.8.5". So the caret is the range almost everyone is using, whether they picked it or not.

Here is what each range expands to, straight from semver.validRange, and which versions from a list of candidates it accepted under semver.satisfies. The candidates were 0.0.3, 0.0.4, 0.0.9, 0.2.3, 0.2.9, 0.3.0, 1.2.3, 1.2.9, 1.3.0, 1.9.9 and 2.0.0.

RangeExpands toAccepted
^1.2.3>=1.2.3 <2.0.0-01.2.3, 1.2.9, 1.3.0, 1.9.9
~1.2.3>=1.2.3 <1.3.0-01.2.3, 1.2.9
^0.2.3>=0.2.3 <0.3.0-00.2.3, 0.2.9
~0.2.3>=0.2.3 <0.3.0-00.2.3, 0.2.9
^0.0.3>=0.0.3 <0.0.4-00.0.3 only
~0.0.3>=0.0.3 <0.1.0-00.0.3, 0.0.4, 0.0.9
^1.2>=1.2.0 <2.0.0-0same as ^1.2.0
1.x>=1.0.0 <2.0.0-0any 1.y.z release

Read the caret as “don’t change the first number that isn’t zero.” On ^1.2.3 that’s the major. On ^0.2.3 it’s the minor, which is why the table shows caret and tilde agreeing there. On ^0.0.3 it’s the patch, so you get exactly one version. Tilde is simpler. It allows patch changes and nothing else, which on ~0.0.3 makes it looser than the caret.

The -0 on every upper bound matters for pre-releases. It means “below the lowest possible pre-release of 2.0.0”, so even 2.0.0-rc.1 is out. It stayed out when I turned on the library’s includePrerelease option. And by default a range doesn’t match any pre-release unless the range itself names one on the same version: ^1.2.3 rejected 1.3.0-beta.1, and ^1.2.3-beta.2 accepted 1.2.3-beta.4 but rejected 1.2.4-beta.1. That’s deliberate. Nobody gets your beta by accident.

Strict spec, lenient tools

What counts as a valid version

Is v1.2.3 a semantic version? The spec’s FAQ says no. It’s a tag name, and the version inside it is 1.2.3. Tools are more forgiving, so it’s worth knowing which way each one leans. From the semver package:

CallReturns
valid('v1.2.3')'1.2.3' (lenient: strips the v)
clean(' =v1.2.3 ')'1.2.3'
valid('1.2')null
valid('01.2.3')null (leading zero)
valid('1.2.3-01')null (leading zero in a numeric pre-release)
valid('1.2.3+01')'1.2.3' (build metadata may have leading zeros)
valid('1.2.3.4')null
coerce('v2').version'2.0.0'

If you need a check outside JavaScript, the spec publishes two official regular expressions at the end of its FAQ, one with named groups and one that works in ECMAScript. Use those rather than writing your own.

Real messages

Errors you’ll hit

I produced each of these on purpose in a scratch folder.

  • TypeError: Invalid Version: 1.2 comes from new SemVer('1.2'). Two numbers aren’t a version. Write 1.2.0, or run untrusted input through coerce first. The same error fires on v1.2.3-01, for the leading zero.
  • npm error Invalid version: 1.5 is npm version 1.5 refusing the same thing. npm version takes a full version or a keyword like patch, minor or prerelease.
  • npm error Git working directory not clean. is npm version patch with a modified tracked file. Commit or stash first. An untracked file didn’t trigger it in my test, only a change to a tracked one.
  • npm error notarget No matching version found for semver@^9.0.0. means the range is valid and nothing published satisfies it. List what does match with npm view 'semver@^7.0.0' version, which prints every published version inside the range.
  • The quiet one: you publish 1.3.0-beta.1, a user on ^1.2.3 runs an install, and nothing happens. No error, which is the problem. Their range excludes your pre-release by design. They have to ask for it by exact version.

Automating the arithmetic

Tools that pick the number for you

Choosing the bump by hand works until the day you forget a breaking change was in there. The tools take the decision off you by reading something you already wrote:

  • release-please reads Conventional Commits and keeps a release PR open with the next version. You merge it to release. This is where the 0.x options above live.
  • semantic-release reads the same commits and releases on every push that warrants one. With no config it uses the Angular preset, which ignores feat!, so check that page before you trust it with a major.
  • Changesets ignores commits. Whoever makes the change writes a small file saying which package gets a major, minor or patch, which suits monorepos where one commit touches three packages.

If you’re still on standard-version, it’s deprecated, and the migration guide covers the ways off it.

Honesty

What this doesn’t fix

A correct version number tells machines, and the few people who read version numbers, what kind of change shipped. It doesn’t tell anyone that something shipped. 1.5.0 sits on the registry and in your changelog, and everyone who doesn’t already watch the repo carries on not knowing about the feature you spent a month on.

That’s the gap I built Merge & Tell for. It reads each merged pull request, the diff and not just the title, and drafts a post for each network. I read every one before it goes out. The updates page is that running on this product.

The version is right. Nobody's heard of it.

You were going to merge the PR anyway.

Semantic versioning (semver): rules, 0.x and npm ranges