Software Architecture System Design

Semantic Versioning in Practice: Breaking Changes, Deprecation, and Trust

Semantic Versioning in Practice shows how you mark breaking changes, deprecate old APIs, and keep the trust of teams that depend on releases you ship.

Executive Summary: Semantic versioning is how you tell another team whether an upgrade is safe, and when the number lies — a breaking change ships inside a minor bump — they pin an old build, skip your security fixes, and you end up supporting a fork you never planned to maintain. This guide covers what actually counts as a breaking change versus what doesn’t, running a deprecation window that gives consumers real time to migrate, and why a clean version number is a promise, not a substitute for a changelog and a test against the previous release.

Semantic Versioning in Practice is how you tell another team whether an upgrade is safe. When the number lies, they pin an old build and skip your security fixes. You then support a fork you did not plan. Still, a tidy version does not replace a changelog or a test against the previous release.

The scheme uses three numbers: major, minor, and patch. If you break a promise, you raise the major. Because people automate that rule, a quiet break in a minor is worse than a loud major. Also, a zero major means you have not promised stability yet, even if someone already depends on you.

What the numbers mean and why they fail

Semantic Versioning defines major, minor, and patch, plus pre-release tags. A patch fixes a bug and does not change the contract. A minor adds behavior old callers can ignore.

When a major rises, callers must read the notes and maybe change code. Then tools can widen or tighten ranges based on those rules.

Go module versions treat a major above one as part of the import path. That makes a break visible at build time, not only at runtime. If your language does not do that, the number is only a social promise.

Therefore you still need tests that compile or run against the last minor. Also, npm semantic versioning shows how caret ranges pull the newest minor, which is why a lie spreads fast.

The scheme fails when teams bump patch for a behavior change because a major feels heavy. A common mistake I have seen is a stricter parser shipped as a patch. Then every consumer build breaks on Monday, and trust drops. After that, people pin exact versions and stop taking fixes.

It also fails when nothing is ever a break. If you only raise patch and minor, the major stays frozen while the API moves. Still, callers who believed you will not read the notes. So the number has to match the pain, or the whole signal dies.

How to apply it on a real release

Start from the public surface, not from the diff size. First, list what another team can call, import, or parse. Next, mark each change as fix, add, or break.

Finally, bump the left most number that the rules require, and reset the numbers to its right. While a huge internal refactor can stay a patch, a one line default change can be a major.

Pre-release tags sit on a version you do not want ranges to pick by accident. Use them for release candidates. If a stable range can resolve to a pre-release, your packaging tool is misconfigured.

Then a Friday pin in CI becomes a half finished API. Also, build metadata should not change which version wins in a compare.

What counts as a break

Removing a function, a field, or a flag is a break. Changing a type, a unit, or the order of a positional argument is a break. Also, rejecting input you used to accept is a break, even when the new rule is more correct. If old behavior was a bug that people depend on, fixing it in patch will still hurt them.

A performance change can be a break when callers relied on a timing accident. It is usually not a break when the contract stays and the call gets faster. When you are unsure, call it a minor and write the note, or call it a major if failure is likely. Still, do not hide it in a patch because the line count is small.

Deprecation before removal

Deprecation is a warning period, not a silent delete. Keep the old path working for at least one minor, and say what replaces it. If you remove it in the next patch, you skipped the promise.

Then raise the major when you finally delete it. Also, make the warning visible in logs or in the compiler, or nobody will notice.

Wire APIs and library APIs share this pain. API versioning covers URI and header majors for running clients. Semantic versioning covers the package those clients compile against.

Because a generated stub can break the build, bump the package major when the generated code breaks. Also, gRPC and REST both need this rule when field numbers or JSON shapes change.

Trade-offs against looser schemes

Use semantic versions when strangers resolve your package with a range. Use a date based scheme when you ship on a calendar and the number is not a compatibility promise. Also, use exact pins inside an app you release as one unit. If you publish a library, ranges are the point, so the numbers must be honest.

First choice.When it fits.If it breaks.
Honest major bump.Use it when old callers must change code or config.You carry two lines until the old major ages out.
Minor add.Use it when new behavior is optional.A strict consumer that rejects unknown fields still crashes.
Patch fix.Use it when behavior and signatures stay put.A behavior fix people relied on still causes pain.
Date based versions.Use them when you do not want ranges to infer safety.Consumers cannot tell a break from a normal Tuesday.

Ranges are where trust turns into traffic. A caret range will take your new minor the next time someone installs. If that minor breaks, you have shipped an incident into their build.

Therefore test the upgrade from the previous minor in CI before you publish. So a version bot without tests is a footgun.

Protocol changes are not package changes. You can ship a minor library that speaks a new optional HTTP header and still be compatible. HTTP/2 and HTTP/3 is a reminder that the wire has its own compatibility rules.

When the library drops an old protocol by default, that default is a break even if the function names stay. Also, keep the old default for a minor cycle when you can.

Pitfalls and failure modes

Diamond dependencies fail when two libraries want two majors of you. However, you cannot always load both in one process. If the majors share a process wide registry, the second init wins and the first caller misbehaves.

Then document whether two majors can coexist, and test that claim. Also, avoid global mutable state so a major bump can sit beside the old one.

Changelog silence is a trust bug. A correct bump with no notes still forces every consumer to diff your source. When the note names the replacement and the date of removal, upgrades get boring.

Still, a note that says misc fixes will train people to ignore you. Because of that, write the break in the first line.

Pre-1.0 abuse is common. Teams ship a zero major for years while companies depend on it, then they break every week. If you have external users, cut a one point oh and start the promise.

Then use pre-release tags for experiments instead of a permanent zero. Also, say in the readme that zero means unstable if you are truly not ready.

  1. First, list the public surface this release can affect.
  2. Next, label each change as patch, minor, or major using the break rules.
  3. Then run the previous release tests against the new build.
  4. After that, write the break or the deprecation in the first changelog line.
  5. Finally, publish so ranges cannot pick a pre-release by mistake.

In my experience, security fixes get stuck because a broken minor taught everyone to pin. While the pin feels safe, it freezes the bug in place. So repair trust with one honest major, a clear note, and a patch that is actually safe. Also, backport the fix to the previous major when people cannot move yet.

A release rule you can copy

The policy below is short enough to put in a repo and enforce in review. It names the bump and the backport. Also, it keeps removals behind a deprecation. If a change does not fit a line, treat it as a major until you prove it is not.

# release rules
# patch: bug fix, same calls, same defaults
# minor: new optional call or field, old callers unchanged
# major: remove, rename, retype, or reject input you once accepted
# deprecate for at least one minor before a major removal
# backport security fixes to the previous major
version: 2.4.1

Tag the same commit you tested. Because a retag can move a version people already resolved, do not reuse a number. Then yank or retract a bad release instead of editing it in place. Also, leave the bad notes up so a pin does not look like a ghost.

Generated code needs the same rule. When a schema change breaks stubs, bump the major of the stub package even if the server still accepts old messages. If the server is compatible but the stub is not, say so in the note. Still, do not force a major on the server artifact when only the generator output changed, unless they ship as one module.

Performance, scale, and cost

Versioning cost is mostly human time and duplicate lines of code. In an illustrative production range, supporting two majors can add a second CI lane and a second on call path for the same bug. If you never bump major, you pay in incidents instead of in branches. Therefore a planned major is cheaper than a surprise break.

Scale shows up as dependency solve time and as lockfile churn. A deep graph with wide ranges forces the solver to walk many tags. Meanwhile, exact pins make installs repeatable and make fixes manual. So libraries should stay honest and small, and apps should pin the result of a solve.

Backports have a cost you should accept for the previous major only. Older than that, security fixes become a negotiation. Also, a major that changes storage format needs a migration plan, not only a number. When the data is incompatible, the version bump is the easy part.

Trust is the asset you scale. After a few honest majors, consumers take minors automatically and your patch reach grows. If you burn them once, adoption of the next fix drops and the vulnerable population stays large. So treat a version bump as part of incident prevention, not as paperwork.

Key Takeaways

  • Also raise major when old callers must change, even if the diff is one line.
  • When you only add optional behavior, a minor is enough.
  • Because ranges pull new minors, a broken minor becomes many teams incident.
  • If you deprecate, keep the old path alive for at least one minor cycle.
  • Still backport security fixes to the previous major so pins can stay safe.
  • Therefore test the upgrade from the last release before you publish the tag.

FAQ

Do I bump major for a bug fix people depend on?

Yes, when correct old programs will fail or change outcome after the fix. A pure crash fix with the same contract can stay a patch. Also, explain the behavior change in the first changelog line. Then consumers can choose the pin with open eyes.

Should internal services use semantic versions?

Use them on libraries and generated clients that other repos import with ranges. A single deployable you always ship from one repo can pin exact builds instead. However, the moment a second team depends on your module, the numbers matter. If you skip the scheme, say so, and do not pretend ranges are safe.

What is wrong with staying at a zero major?

A zero major tells tools and humans that breaks may arrive in any bump. If real users already depend on you, that signal is a trap. Also, some ranges treat zero specially and will not float the way you expect. After you are ready to keep promises, ship a stable one point line and mean it.

How do I earn trust back after a bad minor?

First, retract or yank the bad release so new solves do not pick it. Next, ship a safe patch or an honest major with a clear note. Then add an upgrade test that would have caught the break. Finally, backport the real fix so teams who pin can take it without the break.

Take your next release and label every public change as patch, minor, or major before you pick the number. Run the previous version tests, write the break in the first changelog line, and tag only the commit you tested. After that, watch whether consumers float the minor or pin exact. If they pin, you have a trust bug to fix before you add features.

Last updated on 13 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *