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
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.
feat(search): find frogs by locationfeatscope: searchspec: minor bumpchangelog: Features
Valid under the spec and commitlint’s default config.
fix: stop the map jumping on zoomfixspec: patch bumpchangelog: Bug Fixes
Valid under the spec and commitlint’s default config.
feat!: drop the v1 frogs endpointfeatbreakingspec: major bumpchangelog: BREAKING CHANGES
Valid under the spec and commitlint’s default config.
chore: bump eslintchorespec: no version meaningchangelog: Miscellaneous Chores (hidden)
Valid under the spec and commitlint’s default config.
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.2 | Bump | Next version |
|---|---|---|
| A bug fix that changes no API | patch | 1.4.3 |
| A new feature that breaks nothing | minor | 1.5.0 |
| Marking part of the public API as deprecated | minor | 1.5.0 |
| Anything that breaks the public API | major | 2.0.0 |
| A dependency update, public API unchanged | patch or minor, depending on why | 1.4.3 or 1.5.0 |
- Bumping the minor resets the patch to 0. Bumping the major resets both. There is no
1.5.3straight after1.4.2. - Each number is an integer and compares as one, so
1.9.0is followed by1.10.0, andsemver.gt('1.10.0', '1.9.0')returnstrue. 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.1 | release-please, default | release-please, both pre-major options on | standard-version / commit-and-tag-version |
|---|---|---|---|
feat!: drop the v1 endpoint | 1.0.0 | 0.4.0 | 0.4.0 |
feat: add frog search | 0.4.0 | 0.3.2 | 0.3.2 |
fix: stop the map jumping | 0.3.2 | 0.3.2 | 0.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.0The 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 call | Result |
|---|---|
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.
| Range | Expands to | Accepted |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0-0 | 1.2.3, 1.2.9, 1.3.0, 1.9.9 |
~1.2.3 | >=1.2.3 <1.3.0-0 | 1.2.3, 1.2.9 |
^0.2.3 | >=0.2.3 <0.3.0-0 | 0.2.3, 0.2.9 |
~0.2.3 | >=0.2.3 <0.3.0-0 | 0.2.3, 0.2.9 |
^0.0.3 | >=0.0.3 <0.0.4-0 | 0.0.3 only |
~0.0.3 | >=0.0.3 <0.1.0-0 | 0.0.3, 0.0.4, 0.0.9 |
^1.2 | >=1.2.0 <2.0.0-0 | same as ^1.2.0 |
1.x | >=1.0.0 <2.0.0-0 | any 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:
| Call | Returns |
|---|---|
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.2comes fromnew SemVer('1.2'). Two numbers aren’t a version. Write1.2.0, or run untrusted input throughcoercefirst. The same error fires onv1.2.3-01, for the leading zero.npm error Invalid version: 1.5isnpm version 1.5refusing the same thing.npm versiontakes a full version or a keyword likepatch,minororprerelease.npm error Git working directory not clean.isnpm version patchwith 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 withnpm 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.3runs 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.