This post was originally posted over on my personal website. Maybe you want to check it out [HERE].
Version numbers communicate to their consumer. Use SemVer when code depends on your interface externally. Use CalVer for human-facing software. The question isn't app type — it's: human or code consumer?
Every software project needs a version number. Yet the choice of how to version is often made by habit ("we've always used SemVer") or by imitation ("npm does it, so we will too") rather than by deliberate reasoning. This leads to version numbers that are either misleading, meaningless, or unnecessarily complex.
There is a cleaner mental model: your versioning scheme should match who consumes your software. Humans or code. That single question cuts through most of the confusion.
The Core Distinction: Human Consumers vs. Code Consumers
When a human uses your software, they interact with it through a UI. They click buttons, read screens, fill forms. They don't integrate against your software — they just use it.
When code consumes your software — a library, an API, an SDK — it integrates against a specific contract: function signatures, endpoints, data shapes, CLI flags. A breaking change has a precise, technical meaning: something that worked before now doesn't compile, fails at runtime, or returns unexpected data.
This distinction drives everything:
- Code consumers need SemVer — because a version number is the primary communication channel about compatibility, and dependency resolvers literally parse it to make decisions.
- Human consumers don't need SemVer — because what does a MAJOR bump even mean to a user? That you changed the navigation? Dropped a feature? Added a dark mode? None of SemVer's three signals map cleanly onto the human experience of software.
Semantic Versioning: A Protocol for Dependency Graphs
SemVer uses the format MAJOR.MINOR.PATCH:
- MAJOR — breaking changes; dependents may need to update their code
- MINOR — new features, backwards compatible
- PATCH — bug fixes, backwards compatible
This scheme is elegant precisely because it was designed for a specific problem: how do you communicate compatibility to a dependency resolver that can't read your changelog? Tools like npm, Cargo, and Composer use SemVer ranges (^2.4.0, ~1.2) to automatically resolve compatible versions. The version number is machine-readable API documentation.
SemVer becomes truly necessary when two conditions are met simultaneously:
- Your software has a programmatic interface (API, library, SDK, CLI used in scripts)
- You have external consumers you don't control — third parties who can't update in lockstep with you
Open source libraries are the purest SemVer use case precisely because both conditions are maximally true: thousands of unknown downstream dependents, zero coordination possible. Your version number is the only signal you have.
Where SemVer fits:
- Open source libraries and packages (npm, Composer, Cargo, PyPI)
- Public REST APIs (the underlying package — not the
v1/v2surface, more on that below) - SDKs and client libraries
- CLIs used in scripts and CI pipelines (where a renamed flag is a genuine breaking change)
Calendar Versioning: Honesty for Human Consumers
CalVer encodes the release date into the version number. Common formats include:
| Format | Example | Used by |
|---|---|---|
YY.MM |
25.04 |
Ubuntu |
YYYY.MINOR.PATCH |
2025.1.2 |
JetBrains IDEs |
YYYY.MM.DD |
2025.04.22 |
Internal build artifacts |
YYYYMMDD |
20250422 |
CI/CD pipeline artifacts |
YY.MM.PATCH |
25.04.1 |
Twisted, pip |
For user-facing software, CalVer is more honest than SemVer. The only thing that genuinely matters to a user is: is this newer than what I have, and roughly when was it made? A date answers that directly. 2025.1 tells you more than 4.7.2 ever could, without pretending there's meaningful semantic signal in those numbers.
Ubuntu's YY.MM scheme is a great example: 24.10 came out in October 2024, 25.04 in April 2025. No ambiguity, no inflated MAJOR versions, no "what changed to warrant a 2.0?"
Where CalVer fits:
- Desktop applications with regular release cadences
- Operating systems and distros
- Any user-facing software where "when was this released?" is more useful than "what changed semantically?"
API Versioning: A Special Case
Public APIs have a unique challenge: they are programmatic interfaces (developer-facing), but the version isn't expressed as a package number managed by a resolver. It's expressed in the interface itself — typically in the URL path, a header, or a media type.
Common strategies:
-
Path versioning:
/api/v1/users,/api/v2/users— explicit, widely understood -
Header versioning:
API-Version: 2orAccept: application/vnd.myapi.v2+json -
Query parameter:
/api/users?version=2— simple but pollutes URLs - Evergreen / no versioning: the API evolves without breaking changes, consumers always get the latest
The version number here (v1, v2) is just a sequential integer — what matters is the strategy for where and how you express it. In practice, most teams pair this with SemVer internally: the public API is v2, the underlying codebase is 2.4.1.
GraphQL is interesting here because its design philosophy pushes toward the evergreen approach. Schema evolution should be additive — deprecate fields rather than removing them, add types rather than breaking existing ones. Explicit v2 GraphQL APIs are generally considered a design smell, a sign that the schema wasn't designed for evolution.
The Internal Interface Exception
Having a programmatic interface doesn't automatically mean you need SemVer. A crucial condition is that you don't control the consumers — and this has nothing to do with how your code is organized.
Consider a Laravel application that serves both an Inertia.js frontend and a set of API routes. It's one codebase, one deployment, one composer.json. If those API routes are only consumed by your own frontend or your own mobile app, you own both sides of the interface. You can coordinate breaking changes through a pull request or a Slack message. SemVer's signaling function adds no value because the version number isn't anyone's communication channel — your team is.
The moment third-party developers start integrating against your /api routes, that changes entirely. Now you have external consumers you can't coordinate with, and your API surface needs versioning discipline — expressed via route prefixing (/api/v1/) or headers, regardless of how the underlying code is structured. The application version and the API version then become two separate concerns living in the same codebase, and only one of them needs SemVer.
The key insight is that repository or project boundaries are irrelevant. What matters is the consumer boundary: can you update all consumers when you make a breaking change, or not?
| Consumer type | Scheme |
|---|---|
| Human users | CalVer, or marketing version |
| Programmatic, internal / same team | Optional — coordination suffices |
| Programmatic, external / public | SemVer or explicit API versioning required |
What About Users Who Don't Update?
Mobile apps and browser extensions raise an interesting edge case: users sometimes sit on old versions for weeks or months. Does that make the app's version number developer-facing?
Not really — and the reason reveals something important about where versioning responsibility actually lives.
When an old client is still in the wild, the compatibility burden falls on your backend API, not on the app's version number. Your server has to keep supporting old clients — but that's an API versioning problem. The app version number itself still communicates nothing meaningful to the user in that scenario; they don't inspect 2.4.1 and decide whether to update, they just tap a button in the App Store.
The people who do care about the old app version are your team internally — for analytics ("how many users are still on the old client?"), support triage, and deciding when to sunset old API compatibility. That's a valid operational concern, but it's solved by internal tooling, not by adopting SemVer for the app itself.
The app version and the API version it consumes are two separate things that happen to ship together. Conflating them is a common source of unnecessary complexity.
Platform-Specific Constraints
Some platforms impose versioning formats regardless of your preferences:
-
iOS: requires a user-facing
CFBundleShortVersionStringand an internalCFBundleVersion. Both accept one to three period-separated integers — so1.0,25.04, and2025.4.1are all valid. CalVer like2025.04or SemVer like1.4.2are therefore both technically supported.CFBundleVersionmust increase monotonically with every submission and is what the App Store actually tracks internally. -
Android: the same dual pattern — a human-readable
versionNamewhich is a completely free-form string (any format works, including2025.04or1.4.2), and an integerversionCodefor the Play Store. Unlike iOS,versionCodeis strictly a single positive integer — no dots, no components — and has a maximum value of 2,100,000,000. -
Chrome Extensions: support 1 to 4 dot-separated integers (e.g.
1.0,25.04,1.2.3, or2.0.0.1), each between 0 and 65535. The fourth component is optional — most extensions use three. Chrome uses this number to detect and trigger automatic updates.
In all three cases, users almost never look at the version — the platform handles update prompts. The version number is mostly an artifact for the store and for your own release tracking.
Quick Reference
| Software type | Recommended scheme | Rationale |
|---|---|---|
| OSS library / package | SemVer | Dependency resolvers require it |
| Public REST API surface | v1/v2 path or header | Explicit consumer contract |
| GraphQL API | Evergreen | Additive schema evolution |
| SDK / client library | SemVer, major aligned to API | Mirrors the API contract |
| CLI (used in scripts/CI) | SemVer | Flag changes are breaking |
| CLI (interactive only) | CalVer or SemVer | Either works |
| Desktop app | CalVer | Date is meaningful to users |
| Mobile app | SemVer-like + build number | Platform-dictated hybrid |
| Browser extension | 1–4 part integer version | Chrome supports up to 4 components, 3 is typical |
| OS / distro | CalVer + named releases | Ubuntu, Fedora model |
| SaaS / web app | None public / date+SHA internally | Continuous deployment |
| Internal services | Date+build or sequential | No external consumers |
| Firmware | SemVer or sequential | Clarity over semantics |
Conclusion
The versioning scheme isn't a style choice — it's a communication tool. Before picking one, ask: who needs to understand this version number, and what do they need to know?
If it's a dependency resolver or an external developer integrating against your interface, give them SemVer. They need to know whether your update is safe to adopt automatically.
If it's a human user, give them a date or a name. They need to know whether this is newer than what they have.
And if it's an internal team coordinating a deployment, a Git SHA or a build timestamp is probably enough — you have other channels for communicating what changed.
The mistake isn't choosing the wrong scheme within a category. It's applying a scheme designed for one kind of consumer to an entirely different one.
Top comments (0)