Articles

API Versioning Strategies Explained

URI paths, custom headers, and content negotiation are the three common ways to version an API. Tradeoffs of each, and how to avoid breaking clients.

The Lycoris Team The Lycoris Team · · 4 min read
An abstract blue network mesh

API versioning is how a service signals a breaking change to its interface while keeping older clients working — the three common approaches are putting the version in the URI path, in a custom request header, or negotiated through the standard Accept header, and each makes a different tradeoff between visibility, cache-friendliness, and REST purity.

Why versioning exists at all

Not every API change needs a version bump. Adding a new optional field to a response, or a new endpoint, doesn’t break existing clients — they simply ignore what they don’t expect. A version bump is for breaking changes: removing a field, changing a field’s type or meaning, renaming an endpoint, or altering required parameters. Without a versioning strategy, a breaking change either forces every client to update simultaneously (rarely feasible once an API has external consumers) or forces the API to permanently support the old behavior in the same endpoint, which accumulates conditional logic indefinitely.

URI path versioning

GET /v1/users/42
GET /v2/users/42

The version is baked directly into the URL. This is by far the most common approach in practice, mainly because it’s the most discoverable — anyone reading a URL, a log line, or a curl command can see the version at a glance, with no need to inspect headers.

The tradeoff is that, strictly, a resource’s identity shouldn’t change just because its representation format changed — /v1/users/42 and /v2/users/42 are arguably the same underlying resource, and REST purists argue the version doesn’t belong in the URI at all. In practice this objection rarely stops teams from using it, because the discoverability and simplicity outweigh the purity concern for most APIs.

Header-based versioning

GET /users/42
X-API-Version: 2

The URI stays stable, and the version travels in a custom header instead. This keeps the resource URI clean and arguably more RESTful, and it makes it straightforward to route different versions to different backend implementations at a proxy or gateway layer without touching the URL structure client code depends on.

The cost is discoverability: version information is invisible in a URL you copy-paste, a browser address bar, or a plain server access log — you have to inspect request headers to know what a client actually asked for. It also complicates caching layers that key purely on URI, since two functionally different responses now share the same cache key unless the cache is explicitly configured to vary on the custom header too.

Content negotiation (media type versioning)

GET /users/42
Accept: application/vnd.example.v2+json

This overloads the standard HTTP Accept header — normally used to negotiate response format, like JSON versus XML — to also carry API version information via a custom vendor media type. It’s the approach closest to REST’s original vision of content negotiation, and like header versioning it keeps URIs stable.

It’s also the least discoverable and least commonly implemented of the three in practice: constructing the correct vendor media type string is more friction for API consumers than reading a version number out of a URL, and tooling support (API explorers, quick manual testing with curl) tends to be weaker.

Comparing the three approaches

URI pathCustom headerContent negotiation
DiscoverabilityHigh — visible in the URLLow — hidden in headersLow — hidden in headers
REST purityDebated (resource identity concern)Better (URI stays stable)Best (standard negotiation mechanism)
Cache-friendlinessSimple — URI is the cache keyNeeds Vary header configurationNeeds Vary header configuration
Ease of manual testingEasiest (just change the URL)Requires setting a headerRequires constructing a media type string
Common in practiceMost commonCommon in enterprise/internal APIsLeast common

Deprecating an old version

Shipping a new version is only half the lifecycle — retiring the old one is the half that’s easier to neglect. A deprecation typically goes through an announced timeline rather than an abrupt cutoff: mark the old version deprecated in its documentation and, where the transport supports it, in response headers like Deprecation or Sunset, give clients a concrete removal date, and monitor actual traffic to the deprecated version so you know whether real clients still depend on it before the date arrives. Removing a version that still has active traffic on it, without warning, is the exact failure versioning exists to prevent in the first place — the whole point of having a version boundary is to give clients a chance to migrate on their own schedule rather than being broken without notice.

Avoiding breaking changes in the first place

The cheapest versioning strategy is not needing to bump the version. Additive, backward-compatible changes — new optional fields, new endpoints, new enum values a client should tolerate gracefully — don’t require a new version at all. Reserving version bumps for genuine breaking changes, and designing response schemas so clients are expected to ignore unrecognized fields, keeps the number of times you actually need one of the strategies above much lower than the number of times the API changes. This same design discipline shows up in REST vs GraphQL discussions, since GraphQL’s field-level query model sidesteps some versioning pressure entirely by letting clients request exactly the fields they know how to handle.

The takeaway

URI path versioning wins on discoverability and simplicity and is the default choice for most public APIs; header-based and content-negotiation approaches keep URIs stable at the cost of hiding the version from casual inspection. Whichever approach you pick, reserve version bumps for genuinely breaking changes, keep old versions running as long as clients depend on them, and design new fields to be safely ignorable so most changes never need a version bump at all.

Takina Takina · · 4 min read

What Is a Lockfile? Reproducible Dependency Installs

A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.

#JavaScript #Web Development #Developer Tools
Takina Takina · · 4 min read

JavaScript Intl API: Formatting Dates and Numbers

The Intl API formats dates, numbers, and currency using a user's locale without a library. How Intl.DateTimeFormat and Intl.NumberFormat work.

#JavaScript #Web Development #Developer Tools
Takina Takina · · 4 min read

What Is the Backend-for-Frontend (BFF) Pattern?

A backend-for-frontend (BFF) is a dedicated backend layer for one client type — shaping, aggregating, and simplifying calls to shared downstream APIs.

#Backend #Web Development #Architecture