Version the Promise, Not the Code
If the real contract is what your users depend on, versioning is the mechanism for managing changes to that contract without breaking the people relying on it. Done well, a version number is information: it tells a caller, before they even read the changelog, roughly how much attention this update deserves. Done poorly, it’s just a counter that goes up — every release bumps something, the bump carries no signal, and callers learn to ignore it and find out what changed the hard way, in production. The difference between those two outcomes is whether the version tracks the code or the promise.
Semantic versioning gets at this, but it’s frequently followed as a checklist rather than understood as a discipline. The rule isn’t “bump the major version when you feel like the release is big.” It’s “bump the major version when calling code that worked yesterday might not work today, for reasons the caller didn’t create.” That’s a statement about the contract, not the implementation — you can rewrite half your codebase, improve performance tenfold, and fix a dozen bugs, and if none of that changes what a caller can rely on, it’s not a breaking change, no matter how large the diff. Conversely, a single-line change to a default value can be a major bump if enough people depended on the old default, which the earlier piece on defaults already covered from a different angle — the two ideas are really the same fact seen twice.
Where this breaks down in practice is in the boundary cases, and they’re worth naming because they recur. Fixing a bug is usually not a breaking change — except when people had come to depend on the bug, in which case fixing it is exactly a breaking change no matter how “wrong” the old behavior was. Tightening validation to reject previously-accepted input is a breaking change for whoever was sending that input, even though the new behavior is more correct. Improving an error message’s wording seems harmless until someone’s code was parsing the old message. None of these show up as “obviously breaking” from the implementer’s side, because they’re all judged against the code, not against what’s actually depended on — which is the same gap the earlier piece described, just now showing up at release time instead of design time.
The practical fix is to ask the versioning question from the caller’s side rather than the author’s: not “did I intend to change behavior” but “could code that worked against the old version now behave differently, worse, or not at all.” That question forces you to think about what’s actually observable rather than what you meant to promise, and it’s a more reliable predictor of real-world breakage than intent ever is. It also argues for treating version bumps as a design decision made alongside the change, not an afterthought tacked on right before release — by the time you’re choosing a version number, you should already know whether the change is contract-shaped.
None of this is really about the numbers. It’s about the discipline of asking, for every change, whether it’s shaped like an addition or a break — and communicating that answer honestly to the people who have to decide how urgently to react. A version number that reliably tells the truth is worth more than a rich changelog nobody reads, because it’s the one piece of information every caller sees before deciding whether to update immediately or wait. Get the number right and you’ve done a real service to everyone downstream; get it habitually wrong and the number stops meaning anything, which is its own kind of breaking change — to the versioning contract itself.