REST API 18 ๐ API Versioning Strategies (URI, Query, Header, Content Negotiation)
Versioning is the contract that lets an API evolve without breaking every client at once. The four canonical strategies differ in where the version identifier lives: the URI path (/v1/users), the query string (?version=1), a custom header (X-API-Version: 1), or the Accept media type (application/vnd.example.v1+json). Each choice affects cacheability, discoverability, client complexity, and how cleanly a version can be retired. The practical decision is rarely about REST purityโit is about who consumes the API and whether the infrastructure between client and server can cache responses correctly.
URI path versioning is the most widely deployed because it is the most cache-friendly and the most visible in logs, browser history, and curl commands. The URL itself is the cache key, so CDNs and proxies require no special configuration to store and serve versioned responses independently. The cost is URI proliferation: every breaking change spawns a parallel endpoint tree that must be maintained until the old version is retired. Google’s API Improvement Proposal AIP-185 addresses this by updating channels in placeโmaintaining a stable v1 and a v1beta that must be a superset of the stable channelโrather than minting a v2 for every change.
Header-based versioning keeps URLs clean and is closer to REST’s resource-identity model: /users/3 identifies the same resource regardless of version, and the header selects the representation. The operational cost appears at the edge. Correct caching requires a Vary header listing the version header as a cache dimension, and CDNs create a separate cache entry for each unique combination of Vary values. A high-cardinality version header fragments the cache toward a near-zero hit rate, and some CDNsโAkamai among themโrefuse to cache objects whose Vary header contains anything other than Accept-Encoding. Inside a single origin server or an internal API with controlled clients, this cost is manageable. Behind a public CDN, it is often disqualifying.
Content negotiation via the Accept header is the most “RESTful” in the strict sense: the client asks for a representation (application/vnd.example.v2+json), and the server returns it. HATEOAS links can carry MIME types that encode the version, which makes the approach self-describing. The trade-offs mirror header versioning: clients must understand custom media types, manual testing with curl is more verbose, and caching requires Vary: Accept to prevent a cache from serving a v1 representation to a client that requested v2. Tooling support for custom media types is uneven, and many HTTP clients make header construction awkward.
Query-parameter versioning (?version=1) sits between path and header. It keeps the path clean and is easy to test in a browser, but some older browsers and proxies do not cache responses with query strings, which can degrade performance for web applications. It also pollutes the query string when the endpoint already has parameters, and it complicates HATEOAS link construction just as path versioning does. In practice, it is rarely chosen for new public APIs.
This chapter covers the four strategies, their cache and infrastructure consequences, the deprecation lifecycle that determines how a version is retired, and the decision framework that matches a strategy to the provider’s degree of control over its consumers. It assumes familiarity with HTTP caching and the request/response cycle.
Key point: The version identifier’s location is a caching decision before it is a design decision. URI paths are cache keys by default; headers require Vary and fragment CDN caches. Choose the strategy based on who controls the clients, not on which one looks most RESTful.
Why API versioning exists as a discipline
Breaking changes are inevitable; simultaneous client upgrades are not. A response field is renamed, a required parameter is added, a pagination scheme changes, a status code is repurposed. Every one of these changes breaks at least one client. If the API has no version identifier, the only options are to break clients or to never change the API. Versioning creates a window during which old and new representations coexist, giving clients time to migrate.
Clients are not a single actor. A public API has mobile apps that cannot be force-updated, third-party integrations built by teams the provider has never met, and scripted automations that may not have a maintainer. An internal API may have only a handful of services, all of which the provider deploys. The degree of control over the client set determines how aggressively a version can be retired and how much infrastructure complexity the version scheme can afford.
The cache is an API consumer too. A CDN that caches GET /v1/users without knowing about a version header will serve a v1 response to a v2 client. The fix is a Vary header, but Vary fragments the cache: each distinct version header value becomes a separate cache entry. For a low-cardinality version header (two or three active versions), the fragmentation is acceptable. For a high-cardinality header (per-client version negotiation), the cache hit rate collapses. Some CDNs refuse to cache objects with Vary values beyond Accept-Encoding, which makes header versioning incompatible with their edge caching model.
Deprecation is a process, not an announcement. A version cannot be retired the moment a replacement ships. Clients need notice, a migration window, and a hard cutoff after which the old version returns 410 Gone or a redirect. The versioning strategy determines how visible the deprecation is: a URI path is easy to disable in routing, while a header-based version requires checking the header value in every handler. The retirement mechanism should be designed at the same time as the versioning mechanism.
The trade-off. Every version multiplies the endpoints that must be tested, documented, and maintained. A provider that versions on every change accumulates a maintenance burden that eventually forces a hard cutoffโthe exact outcome versioning was meant to avoid. The discipline is to version only for breaking changes and to keep additive changes (new fields, new optional parameters) in the same version.
a. URI path versioning
The version is a path segment: /v1/users, /v2/users. The URL is the cache key, so CDNs and proxies cache versioned responses without Vary configuration. The version is visible in logs, browser history, curl commands, and documentation URLs.
GET /v1/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
{"id": 42, "name": "Rohit", "email": "rohit@example.com"}
GET /v2/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit", "contact": {"email": "rohit@example.com"}}
The two responses have different shapes, and the paths are different cache keys. A CDN caches them independently without knowing that a version scheme exists. The version segment is the first path component after the host, which makes routing simple: a middleware extracts the segment and dispatches to the version’s router.
The cost is URI proliferation. Each version duplicates the routing tree, and shared logic must be factored into version-agnostic modules or duplicated. The retirement mechanism is straightforward: return 410 Gone for the retired path, or redirect to the current version if the mapping is mechanical. The visibility is the benefit: a log line shows the version, and a dashboard can count requests per version to decide when a version is safe to retire.
b. Query parameter versioning
The version is a query parameter: /users?version=1. The path remains stable across versions, and the endpoint can be tested in a browser by appending the parameter. The cache key includes the query string, so caching works without Vary, but some older browsers and proxies do not cache responses with query strings at all.
GET /users/42?version=2 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit"}
The query string pollutes when the endpoint already has parameters: /users?role=admin&page=2&version=1 mixes the version with filtering and pagination. The version parameter must be stripped before the remaining query is passed to the handler, or the handler must know to ignore it. The retirement mechanism is a handler-level check: if version is a retired value, return 410 Gone.
The strategy is rarely chosen for new public APIs because the browser-caching limitation and the query-string pollution outweigh the clean-path benefit. It is more common in internal APIs where the client set is controlled and the caching limitation does not apply.
c. Custom header versioning
The version is a custom request header, and the response varies on that header. The path is stable across versions, and the resource identity is unchanged: /users/42 is the same resource whether the client asks for v1 or v2.
GET /users/42 HTTP/1.1
Host: api.example.com
X-API-Version: 2
HTTP/1.1 200 OK
Content-Type: application/json
Vary: X-API-Version
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit"}
The Vary: X-API-Version header is mandatory. Without it, a cache treats the request as identical to a request without the header and serves whichever response it stored first. With it, the cache stores a separate entry per version value. The fragmentation cost is proportional to the number of distinct values. Two or three active versions produce two or three cache entries per URL, which is acceptable. Per-client version negotiation produces one entry per client, which collapses the hit rate.
Some CDNs refuse to cache objects with Vary values beyond Accept-Encoding, which makes this strategy incompatible with their edge caching. In a single-origin deployment or an internal API behind a controlled proxy, the cost is contained.
The retirement mechanism requires checking the header value in every handler or in a middleware that normalizes the header to a version number and rejects retired values. The version is not visible in the URL, so log analysis requires parsing the header.
d. Content negotiation versioning
The version is encoded in the Accept media type: Accept: application/vnd.example.v2+json. The server returns a representation whose media type matches the requested version. The resource identity is unchanged, and the approach is self-describing: a HATEOAS link can carry a media type that encodes the version the client should request.
GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
Vary: Accept
{"id": 42, "displayName": "Rohit"}
The Vary: Accept header is mandatory, and it fragments the cache by media type. In practice, the fragmentation is similar to header versioning: a small number of active media types produces a manageable number of cache entries. Custom media types require clients to construct the Accept header correctly, which is awkward in some HTTP client libraries, and manual testing with curl requires -H "Accept: application/vnd.example.v2+json" rather than a simple URL.
The strategy is the most “RESTful” in the strict sense because it uses the HTTP mechanism designed for representation selection. For internal APIs with well-defined tooling, it works. For public APIs with unknown clients, the tooling burden and cache fragmentation usually push toward URI paths.
Complete Example Session
# ============================================
# PART 1: URI PATH VERSIONING
# ============================================
GET /v1/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
{"id": 42, "name": "Rohit", "email": "rohit@example.com"}
GET /v2/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit", "contact": {"email": "rohit@example.com"}}
# Cache key = full path. No Vary needed.
# ============================================
# PART 2: QUERY PARAMETER VERSIONING
# ============================================
GET /users/42?version=2 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit"}
# Cache key = path + query. Version stripped before handler.
# ============================================
# PART 3: CUSTOM HEADER VERSIONING
# ============================================
GET /users/42 HTTP/1.1
Host: api.example.com
X-API-Version: 2
HTTP/1.1 200 OK
Vary: X-API-Version
Cache-Control: public, max-age=60
{"id": 42, "displayName": "Rohit"}
# Vary is mandatory. Cache stores one entry per version value.
# ============================================
# PART 4: CONTENT NEGOTIATION VERSIONING
# ============================================
GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
Vary: Accept
{"id": 42, "displayName": "Rohit"}
# Vary: Accept is mandatory. One cache entry per media type.
# ============================================
# PART 5: DEPRECATION SIGNALS
# ============================================
GET /v1/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Deprecation: @1735689600
Sunset: Wed, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
# Deprecation: RFC 9745 structured field, Unix timestamp.
# Sunset: RFC 8594, HTTP-date when the version is removed.
# Link: successor-version points to the replacement.
# ============================================
# PART 6: RETIREMENT RESPONSE
# ============================================
GET /v1/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 410 Gone
Content-Type: application/problem+json
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
{"type": "about:blank", "title": "API version retired", "status": 410}
# 410 Gone communicates permanent removal.
# ============================================
# PART 7: CACHE FRAGMENTATION MATH
# ============================================
# URI path: 3 versions โ 3 paths โ 3 cache entries.
# Hit rate unaffected; each path has its own traffic.
#
# Header: 3 versions โ 1 path ร 3 Vary values.
# 3 cache entries for the same URL.
# Hit rate divided by 3 if traffic is evenly split.
#
# Per-client header: 1,000 clients โ 1,000 entries.
# Hit rate collapses. Cache thrashes.
# ============================================
# PART 8: ROUTING MIDDLEWARE (URI PATH)
# ============================================
# Extract version segment from path.
# Dispatch to version-specific router.
# Retired version โ 410 Gone with Link header.
# Unknown version โ 404 Not Found.
# ============================================
# PART 9: ROUTING MIDDLEWARE (HEADER)
# ============================================
# Read X-API-Version header.
# Default to stable version if absent.
# Retired version โ 410 Gone.
# Set Vary: X-API-Version on every response.
# Log the header value for per-version metrics.
# ============================================
# PART 10: THE DECISION FLOW
# ============================================
# Public API, unknown clients, CDN? โ URI path.
# Internal API, controlled clients? โ Header or Accept.
# Browser-heavy clients? โ URI path (query-string caching issue).
# GraphQL? โ No versioning; deprecate fields instead.
# Version only for breaking changes. Additive changes stay.
Quick Reference
Strategy Comparison
| Strategy | Cache Key | Vary Needed | Visibility | Cache Fragmentation |
|---|---|---|---|---|
| URI path | Full path | No | High | None |
| Query param | Path + query | No | High | None (if query cached) |
| Custom header | Path + header | Yes | Low | Proportional to version count |
| Content negotiation | Path + Accept | Yes | Low | Proportional to media type count |
Deprecation Headers
| Header | RFC | Value | Purpose |
|---|---|---|---|
Deprecation | RFC 9745 | Unix timestamp | When deprecation started |
Sunset | RFC 8594 | HTTP-date | When version is removed |
Link: rel="successor-version" | RFC 8288 | URL | Replacement endpoint |
Decision Factors
| Factor | Favors URI Path | Favors Header/Accept |
|---|---|---|
| Public API | Yes | No |
| Controlled clients | Neutral | Yes |
| CDN edge caching | Yes | No (Vary fragmentation) |
| Clean resource URLs | No | Yes |
| Tooling simplicity | Yes | No |
Retirement Responses
| Status | Meaning | Use |
|---|---|---|
410 Gone | Permanently removed | Retired version |
404 Not Found | Never existed | Unknown version |
301 Moved Permanently | Redirect to successor | Mechanical migration |
Best Practices
โ Do This:
# Use URI path versioning for public APIs. // โ
GET /v1/users/42
# Set Vary when versioning by header or Accept. // โ
Vary: X-API-Version
# Announce deprecation with standard headers. // โ
Deprecation: @1735689600
Sunset: Wed, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
# Return 410 Gone for retired versions. // โ
HTTP/1.1 410 Gone
# Version only for breaking changes. // โ
# New optional fields stay in the same version.
โ Don’t Do This:
# Version by header behind a public CDN without Vary. // โ
X-API-Version: 2 # Cache serves v1 to v2 clients.
# Mint a new version for every additive change. // โ
/v3 just for a new optional field. Unnecessary proliferation.
# Per-client version negotiation in a header. // โ
X-API-Version: 2.3.1-build.47 # Cache hit rate collapses.
# Retire a version without notice. // โ
# Clients break without migration window.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cache serves wrong version | Header versioning without Vary | Add Vary: X-API-Version |
| CDN hit rate collapses | High-cardinality version header | Use URI path |
| Query string not cached | Older proxy ignores query caching | Use URI path |
| Unreadable curl tests | Custom Accept media type | Use URI path for public APIs |
| Retirement breaks clients | No deprecation window | Deprecation + Sunset headers |
| Version explosion | Versioning additive changes | Version only for breaking changes |
Real-World Examples
1. URI Path Version
GET /v2/users/42
2. Header Version with Vary
GET /users/42
X-API-Version: 2
Vary: X-API-Version
3. Content Negotiation Version
Accept: application/vnd.example.v2+json
Vary: Accept
4. Deprecation Announcement
Deprecation: @1735689600
Sunset: Wed, 01 Jan 2026 00:00:00 GMT
5. Successor Link
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
6. Retired Version
HTTP/1.1 410 Gone
7. Query Parameter Version
GET /users/42?version=2
8. Default to Stable Version
# No X-API-Version header โ serve v1.
9. Version Metrics
# Log version identifier per request.
# Dashboard counts requests per version.
10. Additive Change (No New Version)
# New optional field "nickname" in v1 response.
# Clients ignoring it are unaffected.
Visual
Four Strategies
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ VERSION LOCATION โ
โ โ
โ URI path: โ
โ GET /v2/users/42 โ
โ Cache key: full path โ
โ โ
โ Query param: โ
โ GET /users/42?version=2 โ
โ Cache key: path + query โ
โ โ
โ Custom header: โ
โ GET /users/42 โ
โ X-API-Version: 2 โ
โ Cache key: path + Vary header โ
โ โ
โ Content negotiation: โ
โ GET /users/42 โ
โ Accept: application/vnd.example.v2+json โ
โ Cache key: path + Vary: Accept โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Cache Fragmentation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ VARY FRAGMENTATION โ
โ โ
โ URI path, 3 versions: โ
โ /v1/users โ 1 entry โ
โ /v2/users โ 1 entry โ
โ /v3/users โ 1 entry โ
โ Hit rate: unaffected โ
โ โ
โ Header, 3 versions: โ
โ /users + Vary: X-API-Version โ
โ โ 3 entries for the same URL โ
โ Hit rate: divided by 3 โ
โ โ
โ Per-client header, 1,000 clients: โ
โ /users + 1,000 Vary values โ
โ โ 1,000 entries โ
โ Hit rate: near zero. Cache thrashes. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Deprecation Lifecycle
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ VERSION LIFECYCLE โ
โ โ
โ v2 released โ
โ โ โ
โ โผ โ
โ v1 marked deprecated โ
โ Deprecation: @<timestamp> โ
โ Sunset: <future date> โ
โ Link: rel="successor-version" โ
โ โ โ
โ โผ โ
โ Migration window โ
โ Both versions served. โ
โ Metrics track v1 traffic decline. โ
โ โ โ
โ โผ โ
โ Sunset date reached โ
โ v1 returns 410 Gone. โ
โ Link header still points to v2. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Decision Tree
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHICH STRATEGY? โ
โ โ
โ Public API with unknown clients? โ
โ โโโ URI path versioning โ
โ โ
โ Served through a CDN? โ
โ โโโ URI path (no Vary fragmentation) โ
โ โ
โ Internal API, controlled clients? โ
โ โโโ Clean URLs matter? โ Header โ
โ โโโ Media type tooling? โ Accept โ
โ โ
โ Browser-heavy clients? โ
โ โโโ URI path (query caching issue) โ
โ โ
โ GraphQL? โ
โ โโโ No versioning; deprecate fields โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| URI path | /v1/users; cache key is the path |
| Query param | ?version=1; cache key includes query |
| Custom header | X-API-Version; requires Vary |
| Content negotiation | Accept: application/vnd...; requires Vary: Accept |
| Cache fragmentation | None for URI path; proportional to versions for headers |
| Deprecation | Deprecation (RFC 9745), Sunset (RFC 8594) |
| Successor link | Link: rel="successor-version" |
| Retirement status | 410 Gone |
| Version scope | Breaking changes only |
Key takeaways:
- URI path versioning is the default for public APIs. The URL is the cache key, so CDNs and proxies require no
Varyconfiguration, and the version is visible in logs and curl commands. The cost is URI proliferation and duplicated routing trees. - Header and content-negotiation versioning require
Vary. Without it, a cache serves the wrong version to the wrong client. With it, each version value becomes a separate cache entry, and high-cardinality version headers collapse the hit rate. - Some CDNs refuse
Varyvalues beyondAccept-Encoding. This makes header versioning incompatible with their caching model, which pushes public APIs toward URI paths. - Version only for breaking changes. Additive changesโnew optional fields, new endpointsโdo not require a new version. Versioning every change multiplies the maintenance burden and forces a hard cutoff.
- Deprecation is a process with standard headers.
Deprecationannounces the start,Sunsetannounces the removal date, andLink: rel="successor-version"points to the replacement.410 Gonemarks the retired version. - The decision is about control over clients. A provider that controls the client set can afford header-based versioning’s cache cost and tooling burden. A provider with unknown clients cannot.
Remember: API versioning is a caching decision before it is a design decision. The version identifier’s location determines the cache key, and the cache key determines whether the infrastructure between client and server can serve the right representation without contacting the origin. URI paths are the safest choice for public APIs because the path is the cache key by default. Headers and content negotiation produce cleaner resource URLs, but they require Vary and fragment the cache. Version only for breaking changes, announce deprecation with standard headers, and retire with 410 Gone after a migration window. The strategy that looks most RESTful is not always the strategy that works at the edge.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!