Evolutionversioningcompatibilityuri versioningmedia typedate-based

Versioning: What a Version Even Promises

URI versions, header versions, date-pinned versions, or no versions at all — the strategies differ less than the arguments suggest. What matters is what a version promises, what minting one costs, and why additive evolution is the strategy every good API uses between versions.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What does a version number actually promise consumers, and which changes are worth the price of minting one?
Consumers
Consumers deciding whether an upgrade is mandatory or optional: the partner pinned to v1 since 2023, the mobile app that ships its API expectations into an app store, the internal service that upgrades whenever CI passes.
The promise
A versioning policy tells every consumer which changes can arrive without warning inside their version, which changes require a new version, and how long an old version stays alive — before any of it happens.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

A version is a compatibility promise with a scope

Strip the debates away and a version is one thing: a named scope inside which the provider promises not to break consumers. Everything else — where the number lives, how requests are routed to it — is mechanism. That is why "should we version?" is the wrong first question. The first question is "which changes will we make without a new version, and which never inside one?" — the Backward Compatibility: The Real Rules line. An API with no written line has no versioning policy, whatever its URLs say.

The strategies differ in *granularity* and *visibility*. A URI version (/v2/users) versions the whole API at once and is visible in every log, cache key and bookmark. A media-type or header version (Accept: application/vnd.acme.v2+json) keeps URLs stable but hides the version from anything that only sees the URL. Date-based pinning (Stripe's model: each account is pinned to the API as it looked on a date, upgraded explicitly) versions *behavior* at per-consumer granularity, which is the gentlest for consumers and by far the most machinery for the provider — every old behavior becomes a transformation layer the provider maintains.

The fourth strategy is the one every healthy API uses most: no new version at all. Additive evolution — new optional fields, new endpoints, new enum values under a documented open-enum rule — extends the contract without invalidating any old clause. Version numbers are for the changes additive evolution cannot absorb. An API that mints versions for additive changes is spending its most expensive currency on things that were free.

The strategies, priced honestly
StrategyGranularityWhat it buysWhat it costs
URI (/v1/, /v2/)Whole APIVisible everywhere: logs, caches, curl, routing rules are trivial (see The Gateway as Policy Boundary)All-or-nothing migration for consumers; encourages big-bang v2 projects
Header / media typeWhole API or resourceStable URLs; version negotiable per requestInvisible to URL-only tooling; caches must Vary; harder to demo and debug
Date-pinned per consumerPer account, per behaviorConsumers never break until they choose to upgrade; upgrades are small stepsProvider maintains a transformation chain per breaking change, potentially for years
Additive evolution (no new version)Per field / endpointZero migration cost for every change it can expressCannot express removals, renames or meaning changes; requires discipline and a written safe-change list

The real cost is N versions in production

Minting a version looks like a routing decision; it is actually a product decision. Every live version needs tests, docs, security patches, on-call engineers who remember its quirks, and capacity. The moment v2 ships, you are not "on v2" — you are operating v1 *and* v2 for as long as v1 has consumers that matter, and Public vs Internal APIs says that is measured in years for a public API. Stripe still serves account versions from the early 2010s; that is not an accident, it is the bill for per-consumer pinning, paid deliberately.

This is why version count is a liability metric, not a maturity signal. A team that reaches /v7/ in three years has usually never defined its breaking-change line: each version was minted in a panic, consumers learned that versions are cheap and ignorable, and now no single version has enough consumers migrated to be retired. The opposite pathology is the API stuck on /v1/ shipping silent breaking changes "because v2 is a big project" — a version label that promises nothing.

What one minted version actually costs, over its life
v2 ships
 ├─ v1 keeps: test suite · docs site · security patches · on-call context
 ├─ every bug: "does it exist in v1, v2, or both?"
 ├─ every new feature: "backport to v1 or use as migration bait?"
 ├─ telemetry: per-version consumer burn-down (see [[consumer-driven-evolution]])
 └─ v1 sunset: a deprecation program with a deadline (see [[deprecation]])
     — typical public-API v1 lifetime after v2 ships: 2–4 years

Version the break, absorb everything else

The working policy that falls out: keep one live major version per surface, absorb every change the safe list allows additively, and treat a new version as what it is — a migration program you are asking every consumer to fund. Before minting, three questions: can this change be expressed additively (a new field beside the old, a new endpoint beside the old — see Removing Fields Without Removing Consumers and API Migration: Running the Change End to End)? Is the break confined to one resource, so a resource-scoped revision beats a whole-API version? And is the consumer set small and reachable enough that a coordinated migration beats versioned coexistence entirely?

Note what "version" does *not* promise: it does not make breaking changes safe, it makes them routable. Old clients still have to move eventually; the version only controls when and under whose deadline. Teams that treat /v2/ as the fix for a broken v1 discover that the hard part — moving consumers — has not started yet.

Version minted for a change that was free
1# v1: GET /users/42 → { "name": "Ada" }
2# Requirement: also expose the user's locale.
3
4GET /v2/users/42
5→ { "name": "Ada", "locale": "en-GB" }
6
7# Every consumer is now asked to migrate
8# to receive… a field that could not have
9# broken any of them.
Additive change inside the version; v2 stays in reserve
1# v1, unchanged URL:
2GET /v1/users/42
3→ { "name": "Ada", "locale": "en-GB" }
4
5# Safe because the contract states:
6# - clients MUST ignore unknown response
7# fields (tolerant reader)
8# v2 is reserved for changes that cannot
9# be expressed this way.

A new optional response field breaks no correctly written client. Spending a version on it trains consumers that versions are noise — and noise is fatal to the one version bump that will someday carry a real break.

Key points

  • A version is a named compatibility scope; without a written breaking-change line, the number promises nothing.
  • URI, header and date-pinned versioning differ in granularity and visibility — and all of them cost less than the thing they enable: multiple live versions.
  • Every live version is a supported product: tests, docs, patches, on-call context, and a sunset program at the end.
  • Additive evolution is the default strategy; version numbers are reserved for changes it cannot express.
  • A new version does not make a break safe — it makes it routable. The migration still has to be run and finished.
  • Version count is a liability metric: /v7/ in three years usually means the safe-change list was never written.

Progressive depth

Overview

A version is a way to make a breaking change without breaking anyone — a last resort after additive evolution has been exhausted, not a habit (Backward Compatibility: The Real Rules).

Practical

URI versions (/v2/) are visible and cacheable; header or media-type versions keep URLs stable; date-pinned versions let each consumer freeze behavior. Each has a cost — the URI version fragments the API into parallel codebases the fastest.

Advanced

Dual support is the expensive middle: two representations from one source of truth, contract-tested to agree, with per-consumer telemetry deciding when the old one may go (API Migration: Running the Change End to End, Consumer-Driven Evolution: Telemetry Before Breakage).

Internals

Versions are implemented as translation layers at the edge (request and response adapters per version) or as forked handlers; the adapter approach keeps one domain model and scales to many versions, the fork approach drifts the moment a bug is fixed in only one (The Gateway as Policy Boundary).

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → contract: ships /v1/ without ever defining which changes require a /v2/.
  2. 2
    Team → change: renames a response field inside v1 — "a whole new version for one field is overkill".
  3. 3
    Consumers → production: integrations break inside a version whose label promised stability; trust in the label dies.
  4. 4
    Team → policy: overcorrects and mints a version for every change, additive ones included.
  5. 5
    Consumers → versions: learn to ignore version announcements; the provider ends up operating five half-migrated versions forever.
What breaks
  • Consumers break inside a version they reasonably treated as a stability promise — the worst kind of break, because it was labelled safe.
  • Provider engineering drowns in N-version maintenance: every bug, feature and security patch multiplies by live version count.
  • Migration credibility collapses: once versions have been minted cheaply, the version bump that carries a real break cannot get consumers' attention.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Write the compatibility line first: a published list of changes that arrive without a version bump, and changes that never will (see [[backward-compatibility]]).
  • • Default to additive evolution inside one live major version; require a design review to mint a new one, with the migration program costed in the proposal.
  • • Pick the mechanism by consumer shape: URI versions for broad public APIs and simple routing, date-pinning when you can afford per-consumer transformation layers, coordinated migration with no versions for small internal consumer sets.
  • • Publish version lifecycle rules with the version: support window, deprecation notice period, sunset process — consumers price their integration on this.
Observe in production
  • • Per-version traffic and consumer counts are the health metric: a version whose consumer count is not falling after deprecation is a stalled migration, not a popular product.
  • • Watch support tickets that ask "which version am I on?" — they mean the version mechanism is invisible to consumers (common with header versioning).
  • • Track time-from-v2-launch to v1-sunset per surface; if it trends toward "never", versions are being minted without funded migrations.
Evolve without breaking
  • • A reserved version number is itself evolvable capital: an API that has absorbed three years of change additively can spend v2 on a deep, worthwhile redesign instead of a field rename.
  • • Versioning policy can tighten over time (longer support windows as the consumer base grows) but loosening it — shortening promised windows — is itself a breaking change to the meta-contract.
  • • Resource-scoped revisions (versioning one endpoint's media type) can defer whole-API versions when only one surface needs to break.
What it costs
  • • Additive-first evolution accretes: old field names and compatibility shims accumulate inside v1, and the API's surface is never as clean as a fresh v2 would be.
  • • A strict "versions are expensive" policy slows genuinely needed breaks; teams may contort designs to stay additive when a clean break would serve consumers better.
  • • Date-pinning is the kindest to consumers and the most expensive to operate — the transformation chain is real code with real bugs, tested against real old behaviors.

Misconceptions

Claim
“Putting /v1/ in the URL means the API is versioned.”
Reality
The label without a written breaking-change line is decoration. Versioned means consumers know which changes can arrive inside v1 and which cannot — and the provider keeps to it.
Claim
“A new version is the safe way to ship a breaking change.”
Reality
It is the routable way. The break still lands on every consumer as a migration they must fund; the version only lets old clients keep working while they do. If the migration is never driven to completion, the version made things worse — now there are two APIs.
Claim
“Header versioning is more correct than URI versioning.”
Reality
They trade visibility for URL stability. URI versions show up in every log line, cache key and curl command, which is operationally worth a lot; header versions keep permalinks stable. Neither is purer — pick for your consumers and tooling.