DEV Community

Cover image for Choosing the Right Versioning Scheme: It's About Who Consumes Your Software
Christian Holländer
Christian Holländer

Posted on • Originally published at christian-hollaender.de

Choosing the Right Versioning Scheme: It's About Who Consumes Your Software

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:

  1. Your software has a programmatic interface (API, library, SDK, CLI used in scripts)
  2. 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/v2 surface, 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: 2 or Accept: 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 CFBundleShortVersionString and an internal CFBundleVersion. Both accept one to three period-separated integers — so 1.0, 25.04, and 2025.4.1 are all valid. CalVer like 2025.04 or SemVer like 1.4.2 are therefore both technically supported. CFBundleVersion must increase monotonically with every submission and is what the App Store actually tracks internally.
  • Android: the same dual pattern — a human-readable versionName which is a completely free-form string (any format works, including 2025.04 or 1.4.2), and an integer versionCode for the Play Store. Unlike iOS, versionCode is 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, or 2.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)