Semantic Versioning
A communication convention, not a guarantee. The number is a claim made by a human about their own code, and humans get it wrong in both directions.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
If a patch release cannot break me, why did a patch release break me?
The team wants to accept patch and minor updates automatically and review only majors, so that security fixes land without a meeting.
MAJOR.MINOR.PATCH is a contract. Patch is a bug fix, minor is additive, major is breaking. So we can accept anything below a major automatically and be safe.
It is a contract nobody can enforce. There is no verifier between a maintainer's intent and the number they type, and the number is typed by a release script at the end of a long day.
- It is a contract nobody can enforce. There is no verifier between a maintainer's intent and the number they type, and the number is typed by a release script at the end of a long day.
- "Breaking" is undefined at the edges and everybody draws the line differently: a bug fix is breaking for anyone who depended on the bug, and depending on the bug is extremely common (Characterization Tests is what you needed and did not have).
- Performance, memory and timing changes are not covered at all, and a patch that makes a function ten times slower is entirely conformant and entirely capable of taking down your service.
- Type-level changes are a whole category the convention predates: tightening a type in a published definition file breaks every consumer's build without changing a line of runtime behaviour.
- And the pre-1.0 rule inverts everything — under the convention,
0.xpromises nothing at all, and a large fraction of the packages in a real tree are0.xand have been for years.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- You do not control what the publisher considers breaking; the version number is their opinion about their own change.
- Some ecosystems have hard conventions and some have none, so the same three numbers mean different things depending on where the package came from.
- The auto-merge policy has to be simple enough that people do not route around it.
- Whatever the version says, the build must be reproducible: the resolved version is recorded and reviewed like any other input (Stability and Dependency Direction).
- The claim you rely on is your own test suite, not the publisher's number. Where the two disagree, the tests are the authority.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The publisher owns making an honest claim, and owns a changelog that says what actually changed, because the number alone cannot (Documentation Decay is why the changelog is usually the better source).
- The consumer owns verification. Accepting a version because of its number and shipping without a test run delegates your production behaviour to a stranger's judgement.
- Your build owns recording what was resolved, so that "which version broke it" is a lookup and not an investigation (Stable Identifiers is the same idea applied to entities).
- The number sits at the boundary between two organisations, and its only job is to compress "what did you change" into something a machine can range-match. It is a compression with real losses.
- Inside one repository the convention is usually unnecessary: the build pins everything to a commit, so a shared internal package does not need version numbers so much as a stated stability tier (API Stability).
- The boundary where it genuinely earns its keep is a package with consumers you cannot contact, which is exactly the situation it was designed for.
What each number actually claims
The convention is worth stating precisely, because the useful part is not the rule but the list of things it deliberately says nothing about. That list is where the surprises come from.
Read the right-hand column as the real content of this lesson. Everything there is conformant, unannounced, and entirely capable of taking down a service.
- Major is the reliable signal. A publisher who bumps a major is telling you something true, and acting on that alone catches most expensive breakage.
- Minor and patch are the publisher's belief about their own change, which is exactly as reliable as their release process.
- `0.x` opts out of the whole scheme by design, and a large share of real dependency trees lives there permanently.
1{2 "version": "2.7.3",3 4 "MAJOR 2": "publisher believes this breaks you",5 "MINOR 7": "publisher believes this only adds",6 "PATCH 3": "publisher believes this only fixes",7 8 "not covered": [9 "performance: a patch may be 10x slower and still be a patch",10 "behaviour you depended on that was a bug",11 "published types tightening (build breaks, runtime unchanged)",12 "transitive tree changes: a patch may add 40 packages"13 ],14 "and": "0.x promises nothing at all, by the specification itself"15}Three of the four uncovered categories have no runtime signal until production. That is why the version number cannot be the last line of defence, and why the only enforcement you actually own is your test suite.
How a conformant patch release breaks you
These are not stories about careless maintainers. In every row below the publisher made a defensible call, and the breakage is real anyway — which is the argument for treating the number as a prior rather than a promise.
The response column is where the design content is. Notice that none of the responses is "read more changelogs"; each is a change to how much your code depends on unstated behaviour.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A patch fixes a rounding bug | Your invoice totals move by a cent and reconciliation fails | You depended on the bug, and had a fixture asserting the buggy value | Assert the behaviour you actually require in your own vocabulary, so a change in the dependency fails as a *statement about invoices*, not as a mysterious fixture diff. |
| A patch changes a default timeout from none to 30s | Long-running imports start failing at exactly 30 seconds | You relied on an unstated default rather than setting it | Set explicitly every option whose value you care about. Defaults are the least stable part of any interface. |
| A minor tightens a published type | Build fails on code that has not changed | Types are part of the interface and the convention predates them | Treat a type-only break as breakage regardless of the number; pin exactly so it arrives as a reviewable commit rather than at install time. |
| A minor adds a feature that pulls in a large subtree | Image size jumps, cold start regresses, scanner reports new advisories | The dependency graph is not versioned by the convention at all | Diff the lockfile in review, not just the manifest. A one-line manifest change with a four-hundred-line lockfile change is a big change (Transitive Dependencies). |
| A major is published for a trivial rename | Nothing — it is a ten-minute upgrade | Honest, conservative publishing | Do not learn from this that majors are easy. The next one will not be, and this is how teams end up merging a genuine rewrite on autopilot. |
What to automate, and what to keep in a human's hands
The policy question is not "do we trust semver" but "which decision are we delegating, to whom, and what catches it if we are wrong". Automating the proposal is almost always right; automating the merge is a judgement call that should be made per publisher.
The failure mode to design against is not a bad upgrade — it is a queue of upgrade pull requests that nobody reads, which reliably ends in either a rubber-stamped break or a freeze. Both are worse than either extreme chosen deliberately.
How much do we trust this publisher, and what would catch it if the number is wrong?
when A patch or minor from a project with a changelog, a release process and a history of honest versioning, on a code path your suite genuinely covers.
cost You are trusting your coverage. Where the suite is thin, this is delegation dressed up as automation — and the failure is silent.
when The default for anything on the request path, and for any publisher you have not watched for a year.
cost Real attention every week, most of it spent on changes that would have been fine. This is the cost that makes teams give up, so keep the queue small by upgrading often.
when Development-only dependencies, or a large tree where per-package review is not affordable.
cost Batching means a bad upgrade arrives alongside nine good ones, so the bisect is harder. Acceptable off the request path, dangerous on it.
when A known-bad version, or a major you are not ready for.
cost This is debt and should be recorded as debt, with a date. Pins without expiry are how a four-major jump gets assembled (The Debt Register).
when Any major on a dependency whose vocabulary is in more than one module.
cost It needs a branch, a plan and a migration, and calling it a bump is how it ends up half-done (Designing the Migration).
How to build it
Most important first.
- Read it as a signal with a confidence level, not a guarantee. Major means "the publisher believes this will break you"; minor and patch mean "the publisher did not believe it would". The second claim is much weaker than the first.
- Pin exactly and upgrade deliberately, so that a version change is a commit somebody reviews. Ranges hand the decision to a resolver at install time (Reversible and Irreversible Decisions).
- Automate the *proposal*, never the merge, for anything on your request path. A bot that opens the pull request and runs the suite is the correct amount of automation.
- Weight your trust by publisher, not by number. A project with a release process, a changelog and a test matrix earns auto-merge on patch; a
0.xpackage with one maintainer does not, whatever the number says. - When you publish — internally or externally — make the honest claim even when it is inconvenient. A major version for a small break is cheap; a patch that breaks consumers costs you their trust in every future release (Deprecation).
- Keep a changelog that a human wrote. The number tells a consumer whether to read it; the changelog is what actually tells them what to do.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- With exact pins and a bot that proposes: the next upgrade costs one review and one CI run — minutes — and the cost is paid on your schedule, weekly, in small pieces.
- With ranges and no lockfile discipline: the next upgrade costs nothing until it costs a production incident, and then it costs a bisect against a resolution history you did not record.
- With a freeze: the next upgrade costs whatever accumulated. Skipped upgrades compound roughly with the number of majors crossed, because each one's migration assumes you did the previous one.
- Publishing an honest major costs you one migration note and your consumers one planned migration. Publishing a dishonest patch costs every consumer an unplanned incident, and it is the same amount of change either way.
- Exact pinning gives you reproducibility and takes away automatic security patches, so it creates an obligation to run an upgrade process. Pinning without that process is worse than not pinning.
- Reviewing every bump costs real attention every week, and most of that attention is spent on changes that would have been fine — which is why teams stop doing it, and why the automation has to open the pull request rather than the human.
- Treating minors as risky slows you down and is sometimes right; treating them as safe speeds you up and is sometimes catastrophic. There is no policy that is correct for every package, which is why per-publisher trust beats a global rule.
What can go wrong
- A patch release changes a default. It is genuinely a bug fix from the publisher's point of view and genuinely a behaviour change from yours, and both parties are acting in good faith.
- Auto-merge is enabled, the suite is green because the suite does not cover the affected path, and the change reaches production on a Friday.
- The team responds to one bad patch by freezing all upgrades, and eighteen months later faces a four-major-version jump under a disclosure deadline — the mitigation producing a worse version of the original problem.
- A major version is published for a trivial rename, consumers learn that majors are usually easy, and the one that is not is treated with the same casualness.
- Everything is
0.xforever, which under the convention means the publisher has promised nothing, and the whole policy quietly does not apply to most of the tree.
- Your upgrade policy depends on the publisher's discipline, which you cannot observe directly and can only infer from history.
- Range syntax creates a dependency on the resolver's behaviour and on the moment of installation, which is why two developers on the same commit can be running different code (Transitive Dependencies).
- Peer-dependency ranges create dependencies between your dependencies, so one package's conservative range can pin another package's major for you.
- "Semver is useless." It is a genuinely useful prior. A major version is a reliable signal that the publisher intends to break you, and acting on that alone catches most of the expensive cases.
- "A lockfile makes upgrades safe." A lockfile makes them reproducible. Reproducibly installing a broken version is still broken (Dependency Management).
- "We should never accept minors automatically." For a well-run project with a changelog and a test matrix, automatic minors with a green suite is a reasonable and common policy. The rule should be per-publisher, not global.
- "This is a release-mechanics concern." The mechanics are DevOps'. What lives here is the design consequence: your code makes assumptions about a dependency's behaviour, and the version number is the only place those assumptions are ever represented — badly.
Testing it, and how it ages
- Your own suite, run against the proposed version, is the only enforcement mechanism that exists. Everything else in this lesson is a prior probability.
- Test the behaviours you actually depend on, in your vocabulary, including the ones that feel too obvious to test — the ones a patch release breaks are always the obvious ones (Characterization Tests).
- For a critical dependency, a small contract test over the behaviours you rely on turns "did this patch change anything" from a reading exercise into a CI signal (Contract Tests).
- If you publish, test the compatibility claim: a suite from the previous version, run against the new one, is what makes a patch claim credible.
- Ecosystems drift toward calendar versioning, or toward "we do not break things" as a stated policy, precisely because the three numbers underdetermine the interesting cases.
- Type systems have made the convention weaker over time: a published type definition is part of the interface, and it can break independently of the runtime, which the convention never anticipated.
- The convention will survive because the alternative — reading every changelog — does not scale. Understanding it as a prior rather than a promise is the durable position.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat a version number is a human claim rather than a verified property holds in every ecosystem that uses it, because no package manager checks the claim against the diff — and the few tools that attempt it can only check signatures, never semantics.
- LANGUAGE-SPECIFICGo's module system encodes the major version in the import path, which makes a major upgrade an explicit code change rather than a range resolution and eliminates a whole class of surprise; Rust's cargo treats pre-1.0 minors as breaking by convention, which is stricter than the specification. In npm the convention is followed loosely and
0.xis endemic, so the same policy is far riskier there. Advice about auto-merging minors travels badly between these. - CONTESTEDThe strongest opposing view is that semantic versioning is actively harmful: it encourages publishers to believe they can classify their own changes, licenses the belief that a minor is safe, and produces majors for trivial renames while real breakage ships as patches. Proponents of this view prefer "never break anything, ever" plus dated releases — the approach taken by some standard libraries and long-lived platforms — and they can point to decades of stability as evidence. The counter is that "never break" is available only to projects with the resources to carry every mistake forever, which is not most projects.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether a change is breaking at all depends on the language's linking and typing model: an ABI-compatible change in one language is a rebuild-everything change in another, and the version number cannot express that difference.