Semantic Versioning in Practice — When to Bump Major, Minor, or Patch
Everyone knows MAJOR.MINOR.PATCH. The spec fits on one page and takes four minutes to read. And yet every library maintainer has shipped a patch release that broke someone, because the hard part was never the format — it’s deciding whether the thing you just changed is part of your public API, and whether the people depending on you agree.
The Rule, and Where It Actually Bites
- MAJOR — you broke backward compatibility
- MINOR — you added functionality, compatibly
- PATCH — you fixed a bug, compatibly
The ambiguity lives entirely in “compatibly,” which depends on what you’ve promised. And most projects have never written that down.
Example:
# v1.4.0
class ReportGenerator
def generate(format: :pdf)
# ...
end
def build_header # not documented, but public
# ...
end
end
You rename build_header to render_header in v1.4.1 because it’s an internal detail. Someone was calling it. From their perspective you shipped a breaking change in a patch release. From yours, they were using something that was never part of the API.
Both positions are defensible, which is why the fix is a declaration, not an argument.
Declare Your Public API
Semver only means something relative to a stated surface. Say what’s covered.
Example:
## Versioning policy
This gem follows Semantic Versioning 2.0.0.
**Covered by the compatibility guarantee:**
- Public methods documented in the README and API docs
- Configuration keys accepted by `Config#set`
- The structure of `Report` objects returned from `generate`
- Ruby version support (dropping a supported Ruby is a MAJOR bump)
**Not covered:**
- Anything under `MyGem::Internal`
- Methods marked `@api private` in YARD comments
- Exact wording of log output and error messages
- Database schema of the internal cache table
In Ruby, mark it in the code too:
module MyGem
class ReportGenerator
# @api private
def build_header
# ...
end
end
end
And enforce it where you can:
module MyGem
module Internal
# ...
end
private_constant :Internal
end
private_constant turns a convention into a NameError. It’s the difference between telling people not to depend on something and making it impossible.
The Cases That Trip People Up
1. Bug fixes that people depend on
Your calculate_tax has been rounding down for two years. You fix it to round half-up. That’s a bug fix — PATCH, by the letter of the spec. It’s also a change that alters every invoice total in your users’ systems.
Ship it as MAJOR, or as MINOR behind an opt-in flag. The spec says PATCH; judgment says the blast radius is what matters. Semver is a communication tool, and communicating “nothing will change” when totals will change is a failure regardless of what the rules permit.
2. Adding a required argument
# v1.2.0
def generate(format:)
# v1.3.0 — this is MAJOR, not MINOR
def generate(format:, locale:)
Any new required parameter breaks existing callers. Add it with a default and it’s MINOR.
3. Narrowing what you accept
# v1.2.0 — accepted any object responding to #to_s
def title=(value)
@title = value.to_s
end
# v1.3.0 — now validates
def title=(value)
raise ArgumentError unless value.is_a?(String)
@title = value
end
Tightening validation is a breaking change even though it’s more correct. Someone was passing a symbol and it worked.
4. Adding to a return value
Adding a key to a returned hash is usually MINOR. But if consumers pattern-match exhaustively, or serialize the hash and compare it in tests, or iterate expecting a known set, you’ve broken them. This is why “we return a hash” is a weaker promise than “we return a Report object with these readers.”
5. Dropping a Ruby version
MAJOR. Always. A user on Ruby 3.0 running bundle update should not have their build break because you dropped it in a minor.
6. Dependency bumps
Widening a dependency constraint is MINOR. Narrowing it — requiring a newer version of something — can break a user whose lockfile can’t satisfy both. Treat a raised minimum as MINOR at least, MAJOR if the dependency itself made a breaking change your users will feel.
Version Constraints From the Consumer Side
Semver is a two-sided contract, and how you declare dependencies determines whether it protects you.
Example:
# Gemfile / gemspec
gem "rails", "~> 7.1" # >= 7.1, < 8.0 — allows minor and patch
gem "rails", "~> 7.1.3" # >= 7.1.3, < 7.2 — allows patch only
gem "rails", ">= 7.1", "< 9" # explicit range
gem "rails", "7.1.3" # exact — no updates, including security patches
The pessimistic operator ~> is right for most cases. The rule for the number of segments: more segments means tighter. ~> 7.1 allows 7.9; ~> 7.1.3 does not allow 7.2.0.
For applications, Gemfile.lock pins exactly and the Gemfile constraint just sets the upgrade envelope — be generous there and let the lockfile do the pinning. For gems, be as permissive as you can honestly support, because an over-tight constraint in a library propagates and creates unsatisfiable dependency graphs for everyone downstream.
0.x and the Escape Hatch Nobody Should Overuse
Under the spec, anything can change in a 0.x release. That’s a real freedom during early development and a bad place to live for three years.
If your project is in production at someone else’s company, it’s 1.0 whether the version number says so or not. The 0.x label doesn’t make a breaking change less disruptive; it just removes your obligation to warn them. Ship 1.0 when people depend on it, and use the major number afterwards — that’s what it’s for.
Pro-Tip: Deprecate before you remove, and make the deprecation loud enough to be seen but quiet enough not to be filtered. The pattern that works: in the version where you decide to remove something, keep it working and add a warning that names the replacement and the version it disappears in.
ActiveSupport::Deprecation.new("2.0", "MyGem").warn("build_header is deprecated; use render_header instead")gives users a grep-able string, a deadline, and the fix — all in one line of their log. Then remove it in the major release you named, not the one after, because a deprecation that never resolves teaches people to ignore your warnings. One full minor-release cycle between the warning and the removal is the minimum; two is kinder for anything widely used.
Changelogs Are Part of the Contract
A version number tells you whether something changed compatibly. It never tells you what. That’s the changelog’s job, and a semver-compliant project with no changelog has done half the work.
Example:
## [2.0.0] - 2026-06-23
### Breaking
- `ReportGenerator#generate` now requires a `locale:` argument.
Migration: pass `locale: :en` to preserve existing behaviour.
- Dropped Ruby 3.0 support. Minimum is now Ruby 3.1.
### Added
- `Report#to_csv` for CSV export.
### Fixed
- Tax rounding now uses half-up instead of truncation (#412).
This changes computed totals; see UPGRADING.md.
### Removed
- `ReportGenerator#build_header`, deprecated since 1.5.0.
Every breaking entry should say what to do about it. “Removed X” is a fact; “Removed X, use Y instead” is a migration guide.
Automating the Boring Parts
Conventional Commits give you a machine-readable signal for the bump:
feat: add CSV export → MINOR
fix: correct tax rounding → PATCH
feat!: require locale argument → MAJOR
Tools derive the version and generate a changelog draft from that history. They’re a genuine time saver, with one caveat worth stating: a tool can classify commits, but it cannot know that your “fix” changes every invoice total in production. Keep a human on the major-bump decision.
Conclusion
Semver’s rules are trivial; the work is defining what they apply to. Write down your public API, mark internals as private in code rather than in comments, and accept that blast radius sometimes overrides the letter of the spec — a technically-correct patch release that changes financial totals is still a bad release. Deprecate for a full cycle before removing, keep a changelog that tells people what to do rather than what happened, and use ~> generously in gems so you don’t poison downstream dependency resolution. Done well, a version number becomes something people can act on without reading the diff.
FAQs
Q1: Does adding a new public method require a minor bump?
Yes. New functionality that doesn’t break anything is exactly what MINOR is for. A patch release should contain no new API surface.
Q2: What if I need to make a breaking change urgently for a security fix?
Ship the breaking fix as a major release and backport a mitigation to the current major line if you can. Security patches that require a major upgrade leave users on old versions exposed, which is the outcome you’re trying to prevent.
Q3: How do I version a pre-release?
Append a hyphen and an identifier: 2.0.0-rc.1, 2.0.0-beta.3. Pre-release versions sort before the final release and Bundler won’t install them unless explicitly requested.
Q4: Should internal applications follow semver?
Only where there’s a consumer boundary — a shared internal gem, a service API used by other teams. For a deployable app that nobody depends on programmatically, date-based versioning or plain build numbers carry more information.
Q5: What’s the right constraint for a gem’s own dependencies?
As permissive as you’ve tested. ~> 7.1 on a framework, >= 1.2 with no upper bound on something small and stable. Over-constraining in a library forces conflicts on every application that uses you alongside anything else.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀