- MAJOR for breaking changes, MINOR for new compatible features, PATCH for compatible bug fixes.
- Bumping a number resets the ones to its right: 2.4.7 becomes 2.5.0 or 3.0.0.
- Versions must be compared numerically. As text, 1.10.0 sorts before 1.9.0.
^1.2.3accepts minor and patch updates;~1.2.3accepts patch updates only.
You update a library from 4.17 to 4.18 and nothing breaks. You update another from 2.9 to 3.0 and your build fails in twelve places. If both libraries follow semantic versioning, neither outcome is a surprise, because the version numbers told you what to expect.
We wrote a small Python script to sort versions, bump them and evaluate npm-style ranges, using the rules from the SemVer 2.0.0 specification. The outputs below come from that script, not from npm itself, which has many more edge cases.
What is semantic versioning?
Semantic versioning, or SemVer, is a convention for version numbers in the form MAJOR.MINOR.PATCH, published at semver.org. Each number tells users what kind of change a release contains, so they can decide whether upgrading is safe without reading every line of the changelog.
SemVer only works when a project has a clearly defined public API: the functions, endpoints, options or file formats that users rely on. Compatibility is judged against that API. It is a promise made by maintainers, not something tools can enforce.
The three numbers
| Part | Increase when you… | From 2.4.7 |
|---|---|---|
| PATCH | Fix bugs without changing the API | 2.4.8 |
| MINOR | Add features in a backward-compatible way | 2.5.0 |
| MAJOR | Make incompatible API changes | 3.0.0 |
Our bump function produced exactly these values. Note the reset: a minor release sets PATCH back to 0, and a major release resets both. Deprecating a feature, which warns users that it will be removed later, is a MINOR change. Actually removing it is MAJOR.
What counts as a breaking change?
Anything that could make correct code using the old version fail with the new one:
- Removing or renaming a function, endpoint, field or option.
- Adding a new required parameter.
- Changing a return type or the meaning of a value.
- Changing default behaviour that users depend on.
- Dropping support for a runtime version, such as an old Python or Node.js release. Many projects treat this as MAJOR.
Grey areas exist. Fixing a bug can break someone who relied on the buggy behaviour. Most maintainers treat such fixes as PATCH, but a fix with large impact may deserve a MAJOR release. When in doubt, think about what users experience, not what the code diff looks like. For web APIs, see how versioning fits into REST API best practices.
Version 0.x: anything may change
Versions starting with 0 are for initial development. The specification says the public API should not be considered stable, so any release may break things. In practice, many tools treat the MINOR number as the breaking one for 0.x. Our range check showed this: ^0.3.1 accepted 0.3.9 but not 0.4.0, and ^0.0.3 accepted only 0.0.3 itself. Release 1.0.0 once people depend on your project in production.
Pre-releases and build metadata
A hyphen adds a pre-release label, such as 1.10.0-alpha or 1.10.0-beta.2. Pre-releases come before the normal release with the same numbers. A plus sign adds build metadata, such as 1.0.0+build.5, which is ignored when comparing versions. Our script confirmed that 1.0.0+build.5 and 1.0.0+build.9 compare as equal.
Formatting is strict. Our validator rejected 1.2 (missing a part), 01.2.3 (leading zero), v1.2.3 (prefix) and 1.2.3.4 (four parts). The “v” is common in Git tags, such as v1.2.3, but it is a tag naming habit, not part of the version itself.
Sorting versions correctly
This is a real bug in many scripts and dashboards:
sorted(["1.9.0", "1.10.0", "1.2.3", "2.0.0"])
# ['1.10.0', '1.2.3', '1.9.0', '2.0.0']
Sorted as text, “1.10.0” comes first because the character “1” is less than “9”. Comparing each number as an integer, with SemVer’s pre-release rules, gives the right order:
['1.2.3', '1.9.0', '1.10.0-alpha', '1.10.0-beta.2',
'1.10.0-beta.11', '1.10.0', '2.0.0']
Look at the beta versions. Numeric pre-release parts are compared as numbers, so beta.2 comes before beta.11, and every pre-release comes before the final 1.10.0. In Python, the packaging library does this for Python’s own version scheme, PEP 440, which differs from SemVer in details. For example, it normalised 1.10.0-beta.2 to 1.10.0b2. Use a tested library rather than writing your own in production. A dataclass with order=True is fine for simple, purely numeric versions.
Caret and tilde ranges
Package managers such as npm use SemVer to decide which updates to install automatically. We applied both common operators to a list of candidate versions:
candidates: 4.17.20, 4.17.21, 4.18.0, 4.99.1, 5.0.0, 5.0.0-rc.1
^4.17.21 -> 4.17.21, 4.18.0, 4.99.1
~4.17.21 -> 4.17.21
| Range | Meaning | Accepts |
|---|---|---|
^4.17.21 |
Same major, at least this version | >=4.17.21 and <5.0.0 |
~4.17.21 |
Same minor, at least this version | >=4.17.21 and <4.18.0 |
4.17.21 |
Exactly this version | 4.17.21 only |
>=4.17 <5 |
Explicit range | As written |
The caret is npm’s default when you install a package. It trusts the library to follow SemVer: new features and fixes are welcome, breaking changes are not. Pre-releases such as 5.0.0-rc.1 were excluded, as npm also excludes them unless you ask for them explicitly. Other ecosystems use similar ideas with different syntax, such as Python’s compatible-release operator ~=.
Lockfiles: ranges are not enough
Ranges describe what is acceptable. Lockfiles, such as package-lock.json, poetry.lock or uv.lock, record the exact versions actually installed, so every developer and every CI run uses the same set. Commit the lockfile for applications. Then update dependencies on purpose, with tools such as Dependabot or Renovate opening pull requests that your test suite checks. Even honest PATCH releases occasionally break things, and your tests are the safety net.
Tip: Treat a dependency’s MAJOR update as a small project. Read the changelog’s breaking changes section, upgrade on a branch, and run your full regression checks before merging.
Automating releases
Choosing the next version by hand is error-prone. A popular approach uses Conventional Commits, a message format such as fix: handle empty cart, feat: add coupons or feat!: remove v1 API. Tools such as semantic-release or release-please read these messages and decide the bump: fix means PATCH, feat means MINOR, and ! or a BREAKING CHANGE footer means MAJOR. They then tag the release, write the changelog and publish, usually from a CI pipeline. We did not run these tools for this article.
When SemVer does not fit
- Websites and hosted services with no public API often use build numbers or dates instead.
- Calendar versioning (CalVer), such as Ubuntu’s 24.04, suits products released on a schedule.
- Apps released through stores need versions that make sense to users and store rules.
Whatever scheme you choose, document it so users know what a new number means.
SemVer in practice for maintainers
Write a changelog people can scan
The version number says what kind of change happened; the changelog says what changed. A common format groups entries under headings such as Added, Changed, Deprecated, Removed, Fixed and Security, with the newest release at the top. Put breaking changes first in a major release, and include a short migration note for each one: what to change, with a before-and-after example if possible.
Deprecate before you remove
A smooth path to a major release looks like this. In a minor release, mark the old function or option as deprecated, emit a warning when it is used, and document the replacement. Give users at least one or two minor releases to move. Then remove it in the next major release. Users who read warnings will have no surprises.
Check compatibility automatically
People miss breaking changes in review, especially in large diffs. Tools exist for many ecosystems that compare the public API of two versions and flag removals and signature changes, such as cargo-semver-checks for Rust, japicmp for Java, API Extractor for TypeScript and griffe for Python. Running one in CI turns “did we break the API?” from a judgement call into a check. We did not run these tools for this article.
Support policy
Say which major versions still receive fixes, for example “the latest major gets features and fixes, and the previous major gets security fixes for 12 months”. Clear policies help users plan upgrades instead of being forced into them.
Final thoughts
Semantic versioning turns a version number into a compatibility signal. Increase MAJOR for breaking changes, MINOR for new features and PATCH for fixes. Compare versions numerically, understand what ^ and ~ will install, and commit a lockfile so updates happen when you choose, not by surprise.
Frequently asked questions
What is semantic versioning?
It is a convention for version numbers in the form MAJOR.MINOR.PATCH, where each part signals breaking changes, new compatible features or compatible bug fixes.
When should I increase the major version?
When a release makes an incompatible change to the public API, such as removing a function, renaming a field or adding a required parameter.
What is the difference between ^ and ~ in package.json?
^1.2.3 accepts any version below 2.0.0, so minor and patch updates. ~1.2.3 accepts versions below 1.3.0, so patch updates only.
Does 1.0.0-beta come before 1.0.0?
Yes. Pre-release versions have lower precedence than the normal release with the same numbers.
Why does 1.10.0 sort before 1.9.0?
Because it is being sorted as text. Compare each part as a number, or use a version library, to get the correct order.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.