Semantic Versioning Isn't Always the Right Fit

Matt Mann | Jul 5, 2026 min read

In my current org I noticed something peculiar about the version numbers we stamp on every release. For example, the version identifier might follow a pattern that looks like: 100.00.00. Each release candidate the last number would increment (e.g. 100.00.01). If there was a hotfix, theoretically the middle number would increment (e.g. 100.01.00). Next release, increment the first number (e.g. 101.00.00). But in practice, I never saw that middle number increment — we were definitely doing hotfixes, but the middle number never changed…

I later learned that the org started using dates instead of the middle number, to make it extra clear for release management (RM) what commit was supposed to be deployed for hotfixes. So instead of seeing 100.01.00, you might see a branch or tag named 2026-01-01. Over time, the middle number became vestigial.

The problem came when I was asked to document this. As it currently stood, I would have to explain the history of our release versioning system, and the lore surrounding its middle number. So instead I used this as an opportunity to simplify our versioning system (see below).

When I brought my proposal to some stakeholders, one suggested that we consider adopting semantic versioning, even hinting that we should be using it for everything. So, that discussion inspired me to write about why I don’t believe SemVer is a good fit for all software artifacts. I also want to point out that despite our current versioning system being a little confusing to newcomers, it had been working for years, so we don’t want to discard the lessons encoded in it. The emergent result ended up formalizing the latent structure of our versioning system, but using some ideas from SemVer to fix the problems we had with it.

But first, some history.

Why Do We Use Release Versions at All?

Why do we stamp a “v-something” on software in the first place? There are actually examples going all the way back to the scribes of ancient Rome, through to the printing press.

Ancient Roman publishers had a concept of production runs. By one scholarly account (argued by the historian T.C. Skeat), a reader would position himself before a room of scribes and speak loud enough for all of them to write the full dictation, and no copy left the establishment until the whole edition (or release) was complete. Naturally there were some differences between each copy, despite efforts to keep them the same. So an edition didn’t signal that every copy in the set contained exactly the same thing, but rather who wrote it, and when it was written.

When Gutenberg invented the printing press, essentially identical copies of books could be distributed. Now editions became tied to identical groups of changes that you’re shipping.

Early software was published similar to books, but we said “versions” instead of editions. You would see version 1, 2, etc… At some point people saw fit to break that number into two pieces to roughly mean “big changes and little changes.” Little changes could mean bug fixes or minor enhancements. Larger enhancements would receive the honor of incrementing “the big number,” partially to connote some degree of fanfare (see aside). Around 2010, Tom Preston-Werner formalized a spec that we call “semantic versioning,” or “SemVer” (the 2.0.0 revision we use today landed in 2013). SemVer brought strict specifications surrounding the meaning of the three-part version number that was starting to gain traction at the time for dependency management: the last little number belongs to bug fixes alone.

Aside: Tom Preston-Werner (SemVer’s inventor) critiqued that developers have become psychologically reluctant to bump the major version even when a breaking change happens, because a major bump feels high-stakes. So teams concoct excuses (“not many people use that feature yet”) to smuggle breaking changes into minor releases. Personally, I think developers have always tended to reserve major increments for larger changes. Linus Torvalds has been saying as much for years — when announcing Linux 4.17-rc1 he warned that “v5.0 will happen some day. And it should be meaningless.”

Sometimes the big number isn’t engineering at all — it’s marketing. Sun rebranded JDK 1.2 as “Java 2” because “.2” didn’t sound like a leap. Slackware jumped straight from version 4 to 7 in 1999, partly to stop looking “behind” distros whose numbers had inflated. Winamp skipped 4 entirely and shipped Winamp 5, joking that it was Winamp 2 + Winamp 3. When the number’s job is fanfare, it stops carrying technical meaning, which is exactly the baggage Preston-Werner says SemVer still hasn’t shaken.

Why Not SemVer?

SemVer Excels at APIs

SemVer assumes a testable contract — code depending on specific behavior. That assumption fits libraries and APIs well, but what about other types of software artifact? A UI has no code depending on it. Is a button’s location part of the contract? Are you going to increment your Major version if one of your Playwright tests breaks? For the most part, the end consumer of the UI doesn’t even care what version they’re using as long as it works.

What about an operating system? For Ubuntu, Canonical uses dates as the version number (e.g. 24.04.2 meaning year.month.patch). Imagine if they tried to use SemVer instead… What recent changes would trigger a major versus minor increment? And could you get people to agree on it?

Internal vs. External Dependencies: Black Box vs. White Box

When you consume an external vendor’s API, you’re dealing with a black box. You can’t see their code, and if you can it might be read-only or have a lengthy PR process. So their version number is your only proxy for that org’s intent around a release (more on intent below).

Internally, the situation is white box — you can read the code, run the tests, even send the fix yourself. Yet people try to construct the same kind of black-box contract internally via microservices to deploy components independently. But internally, we have full control over our contracts, so do we really need to rely on an opaque version number for this? Instead we should probably just incorporate contract tests into our CI/CD pipelines.

I’m not alone in questioning this. Robert C. Martin (Uncle Bob) puts it bluntly: “Microservices are not an architecture, they are a deployment strategy” — the decoupling should exist regardless of whether a service boundary imposes it. Kelsey Hightower makes the same point from the other direction, mocking teams that reach for microservices to fix a discipline problem: “We’re gonna break it up and somehow find the engineering discipline we never had in the first place.” If the discipline lives in your code and your contract tests, the version number on an internal service stops being a load-bearing contract — its only remaining job is to tell you what’s deployed.

Why Separate Major, Minor and Patch?

Why separate the concept of “big changes, and little changes?” SemVer does suggest that we update the Major number whenever we introduce a breaking change, but that doesn’t necessarily mean that the change in question broke your project - just that it’s higher risk.

What about “minor” vs “patch?” In theory an enhancement is more likely to carry its own bugs, so a patch-only upgrade is safer than an enhancement upgrade. But this “more change, more risk” idea doesn’t hold up if you’re using something like a trunk-based branching model without release branches.

If you release straight from trunk, there’s no isolation boundary between code containing enhancements versus just bug fixes. Is your feature flag system airtight enough to create a logical boundary? Possibly not. And if there’s no branching separation between patch and enhancement, every upgrade carries the same risk profile — at which point the distinction between Minor and Patch stops adding value.

SemVer Is About Intent and Resolution, Not Adherence to a Contract

Even in libraries and APIs, SemVer doesn’t hold up perfectly. Hyrum’s Law — named for Google’s Hyrum Wright and popularized in the book Software Engineering at Google — observes that with enough users of an API, it does not matter what you promise in the contract — all observable behaviors of your system will be depended on by somebody. SemVer asks you to classify every change as breaking or non-breaking, which presumes you know your public API surface precisely. Hyrum’s Law says you don’t: your real surface is every behavior anyone can observe.

SemVer usually can’t keep the promises it wants to make. What it can establish is intent.

Here’s a scenario: while upgrading an external library, a test of yours breaks when you bump the minor version of its package. You send the test, or something that can reproduce the failure, upstream as an issue. There are two possible responses the maintainer could give you:

  1. “Yeah, that was a mistake” — they incorporate your test and ship a patch that fixes it. Backward compatibility was the intent all along.
  2. “No, you shouldn’t have been depending on that behavior.” The boundary is clarified: that was never part of the contract.

Either way, the boundary becomes explicit. That’s how you operationalize around Hyrum’s Law: intent is made defensible through dialogue, not through the version number alone.

The caveat is that this social contract is built on tests. Tests give a definitive statement about where you stand with the software. Without them there’s no basis for the dialogue — no objective way to say “this broke.” Tests make intent verifiable.

Using Chesterton’s Fence to Derive a Versioning System

So much for libraries and APIs. Back to the versioning scheme that started all this.

Chesterton’s Fence is a principle that says you shouldn’t remove a fence until you understand why it was put up in the first place. We discussed above why the old system was put in place, and why it was failing. Now we can use that understanding to derive a new system.

The root cause is that the old system used the last set of numbers for the release candidate. That leaves you with a set of versions that were never intended to be deployed to production. When you roll back, you want to make sure you’re rolling back to a production-ready version, not one of the old release candidates. So dates got used to prevent mistakes. But mixing the formats of your release numbers creates its own problems. Suppose we’re on release version 2025-04-01 — do we roll back to 2025-03-15 or 101.00.00? You now have to check the date that 101.00.00 was released.

So the lesson is: we only increment version numbers for production artifacts, and all artifacts must use the same versioning format. How does RM then know what to deploy to lower environments? We borrow one idea from SemVer: pre-release identifiers. We can use a pre-release identifier to encode the release candidate number, and RM can use that to know what to deploy. So you might use a tag in git that looks like 100.00-rc1.

That’s one fence understood. Here’s a second, smaller one: the zeros. SemVer prescribes that we don’t include leading zeros in our release versions. But, in 100.00.00, those zeros aren’t decoration — zero-padding is useful for lexicographic sorting. When something sorts version strings as text rather than parsing them numerically, a padded 1.02.0 sorts correctly against 1.10.0, whereas an unpadded 1.2.0 lands in the wrong place. Some tool, somewhere, was sorting our versions as strings. Adopting SemVer wholesale would have torn this fence down without ever asking why it was there. It’s a small example of the bigger theme: an org’s homegrown scheme often encodes a real operational lesson that a standard written for dependency management has no reason to care about. So the padding stays.

Alternatively, we could just embrace dates like Canonical does (e.g. 2026.04.00). That’s perfectly sortable; allows us to control deployment risk via our release branch; and gives RM what they wanted in the first place.

A Pragmatic Flowchart for Choosing a Versioning System

  1. Are you an API or a library that upstream code depends on?
    • Yes → SemVer is a good fit — but you need solid tests verifying the contract you provide, and an operational process for upstream developers to report “you broke my test.” (And that developer has to actually have the test.)
    • No → go to 2.
  2. Per your risk profile, is it valuable to separate the concept of release from hotfix?
    • Yes → you probably need two numbers, e.g. 1.1 / 2.2 (major.minor). We’ve gone from three numbers down to two.
    • No → a simple sequential numbering system is sufficient to establish the order that things were released in.

Conclusion

Version numbers have never really been about the numbers. For the Romans, an edition told you who wrote a work and when. After Gutenberg, it told you which batch of changes you held in your hands. SemVer added one more job: telling dependent code whether it’s safe to upgrade. That’s a genuinely great tool for the job it was designed for — libraries and APIs with testable contracts — but it’s a tool, not a universal standard, and thanks to Hyrum’s Law even its home turf requires tests and dialogue to make the promises stick.

For everything else — UIs, internal services, deployment artifacts — the version number’s job is whatever your operations need it to be: sorting correctly in a dashboard, telling RM what’s safe to roll back to, marking what’s production-ready. Your org’s existing scheme, however crusty it looks to newcomers, probably encodes hard-won answers to exactly those questions. So before you standardize on SemVer because a slide deck said to, take your current scheme and reason through what actually brings value to your org: keep the parts that encode a real lesson, and trim the fat. Understand the fence before you tear it down — and if you find a vestigial middle number of your own, now you know what to do with it.

References