Skip to content

Instantly share code, notes, and snippets.

@jashkenas
Last active June 19, 2025 18:41
Show Gist options
  • Save jashkenas/cbd2b088e20279ae2c8e to your computer and use it in GitHub Desktop.
Save jashkenas/cbd2b088e20279ae2c8e to your computer and use it in GitHub Desktop.
Why Semantic Versioning Isn't

Spurred by recent events (https://news.ycombinator.com/item?id=8244700), this is a quick set of jotted-down thoughts about the state of "Semantic" Versioning, and why we should be fighting the good fight against it.

For a long time in the history of software, version numbers indicated the relative progress and change in a given piece of software. A major release (1.x.x) was major, a minor release (x.1.x) was minor, and a patch release was just a small patch. You could evaluate a given piece of software by name + version, and get a feeling for how far away version 2.0.1 was from version 2.8.0.

But Semantic Versioning (henceforth, SemVer), as specified at http://semver.org/, changes this to prioritize a mechanistic understanding of a codebase over a human one. Any "breaking" change to the software must be accompanied with a new major version number. It's alright for robots, but bad for us.

SemVer tries to compress a huge amount of information — the nature of the change, the percentage of users that will be affected by the change, the severity of the change (Is it easy to fix my code? Or do I have to rewrite everything?) — into a single number. And unsurprisingly, it's impossible for that single number to contain enough meaningful information.

If your package has a minor change in behavior that will "break" for 1% of your users, is that a breaking change? Does that change if the number of affected users is 10%? or 20? How about if instead, it's only a small number of users that will have to change their code, but the change for them will be difficult? — a common event with deprecated unpopular features. Semantic versioning treats all of these scenarios in the same way, even though in a perfect world the consumers of your codebase should be reacting to them in quite different ways.

Breaking changes are no fun, and we should strive to avoid them when possible. To the extent that SemVer encourages us to avoid changing our public API, it's all for the better. But to the extent that SemVer encourages us to pretend like minor changes in behavior aren't happening all the time; and that it's safe to blindly update packages — it needs to be re-evaluated.

Some pieces of software are like icebergs: a small surface area that's visible, and a mountain of private code hidden beneath. For those types of packages, something like SemVer can be helpful. But much of the code on the web, and in repositories like npm, isn't code like that at all — there's a lot of surface area, and minor changes happen frequently.

Ultimately, SemVer is a false promise that appeals to many developers — the promise of pain-free, don't-have-to-think-about-it, updates to dependencies. But it simply isn't true. Node doesn't follow SemVer, Rails doesn't do it, Python doesn't do it, Ruby doesn't do it, jQuery doesn't (really) do it, even npm doesn't follow SemVer. There's a distinction that can be drawn here between large packages and tiny ones — but that only goes to show how inappropriate it is for a single number to "define" the compatibility of any large body of code. If you've ever had trouble reconciling your npm dependencies, then you know that it's a false promise. If you've ever depended on a package that attempted to do SemVer, you've missed out on getting updates that probably would have been lovely to get, because of a minor change in behavior that almost certainly wouldn't have affected you.

If at this point you're hopping on one foot and saying — wait a minute, Node is 0.x.x — SemVer allows pre-1.0 packages to change anything at any time! You're right! And you're also missing the forest for the trees! Keeping a system that's in heavy production use at pre-1.0 levels for many years is effectively the same thing as not using SemVer in the first place.

The responsible way to upgrade isn't to blindly pull in dependencies and assume that all is well just because a version number says so — the responsible way is to set aside five or ten minutes, every once in a while, to go through and update your dependencies, and make any minor changes that need to be made at that time. If an important security fix happens in a version that also contains a breaking change for your app — you still need to adjust your app to get the fix, right?

SemVer is woefully inadequate as a scheme that determines compatibility between two pieces of code — even a textual changelog is better. Perhaps a better automated compatibility scheme is possible. One based on matching type signatures against a public API, or comparing the runs of a project's public test suite — imagine a package manager that ran the test suite of the version you're currently using against the code of the version you'd like to upgrade to, and told you exactly what wasn't going to work. But SemVer isn't that. SemVer is pretty close to the most reductive compatibility check you would be able to dream up if you tried.

If you pretend like SemVer is going to save you from ever having to deal with a breaking change — you're going to be disappointed. It's better to keep version numbers that reflect the real state and progress of a project, use descriptive changelogs to mark and annotate changes in behavior as they occur, avoid creating breaking changes in the first place whenever possible, and responsibly update your dependencies instead of blindly doing so.

Basically, Romantic Versioning, not Semantic Versioning.

All that said, okay, okay, fine — Underscore 1.7.0 can be Underscore 2.0.0. Uncle.

(typed in haste, excuse any grammar-os, will correct later)

@robnagler
Copy link

@oisin writes:

Developers lives are not easier and are not getting easier and it's a pain in the neck, but now I have gone entirely off-topic.

I like to think about why developers lives are not getting easier. I think by choosing to "move fast and break things", we are causing our own headaches. Consider how smoothly developers survive major versions of the Linux kernel, make, gcc, libc, and other tools, libraries, and languages that "don't break userspace". I can run a 40 year old Makefile today to compile a program which will run correctly (see my 2015 article Major Release Syndrome).

SemVer seems to give us a license to ignore backward and forward compatibility.

@mindplay-dk
Copy link

And you haven't slipped a single bug in half a decade? 😬

Of course.

If we accidentally shipped a breaking change as minor, a patch release would be issued, reverting that change.

Then a major release would be shipped.

And are all of your dependencies and subdependencies following SemVer to the letter, without exceptions?

We can't control what third-party dependencies do - but for some reason, most of the third-party Composer (PHP) packages we used were very well versioned. NPM for some reason is much worse about this. It could be cultural, in part, but I suspect maybe a large factor is PHP has type-hints. (Not sure if the situation is better for TypeScript packages? I haven't really thought about it before.)

And there hasn't been a single bug in them?

Everyone ships bugs, I'm not claiming we did it perfectly - we took versioning seriously, that's all. 😊

There's no concept of "perfect SemVer", it's SemVer or not Semver.

That is the hard line stance we took as well.

But is it always worth the costs it can sometimes incur on users?

That is entirely up to the user to decide. Take the cost, or keep the current version. If I update a thing, it's up to the consumers of the thing to make the call on the upgrade, not my call as a producer of the software package/item.

Exactly this.

By liberally increasing the major version whenever it seems prudent, your users get to make a conscious and deliberate choice - we also maintained a mandatory change log explaining any breaking changes, why they were made, and how to upgrade.

every bug is a SemVer violation! 🤯

Not strictly wrong.

But of course, the intention with SemVer is to document/specify with version numbers what you know.

Bugs aren't normally known at the time of release, and so of course no one can be expected to get this right 100% of the time.

What we can do, is roll back a breaking change that we versioned as a compatible change, and correct the mistake - every bug is a SemVer violation, but one that you've committed to correcting with another release to restore SemVer compatibility.

I confront developers about it — and they respond that it was private API that changed and they never promised it to be stable.

For internal members, we used the @internal annotation - calling an internal constructor (or method) will highlight with a warning in most IDEs. Users should understand what this means - if you're using something that's internal to the package, it's subject to refactoring, and there are no guarantees or commitment that it'll even exist in the next release. We taught our team to do this.

SemVer is not a religious thing at all, it's a behavioural commitment that a project gives to its consumers.

💯

@pauldraper
Copy link

pauldraper commented Jun 19, 2025

every bug is a SemVer violation! 🤯

They are GENERAL API specification violation, regardless of what versioning you use.

When that happens, the appropriate response to fix the bug in a new patch release.

Thankfully, SemVer helps us communicate that! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment