API Versioning Decision Tree: URL Path vs Header vs Content Negotiation

May 25, 2026 · 15 min read

Every API that lives long enough will need a new version. A field type changes from string to integer. A required field becomes optional. An authentication scheme switches from API keys to OAuth. The question is not whether you will version your API, but which versioning strategy you will use. The wrong choice creates years of client migration pain, routing complexity, and documentation confusion. The right choice depends on your specific constraints: who consumes the API, how many clients exist, how often breaking changes ship, and whether cacheability matters.

This guide provides an interactive decision tree that asks the right questions and recommends a versioning strategy based on your answers. Below that, a comparison table scores all four major strategies across eight dimensions, and a migration complexity calculator estimates the engineering effort required to implement each approach. Every recommendation includes production-ready code examples for Express, FastAPI, and nginx so you can implement immediately.

Interactive Decision Tree

Answer the questions below to get a tailored versioning strategy recommendation. Each question narrows the options based on your specific constraints. The decision tree considers five factors: API audience, client count, breaking change frequency, caching requirements, and URL cleanliness preference.

Versioning Strategy Decision Tree

Who consumes your API?

Strategy Comparison Table

This table scores all four versioning strategies across the dimensions that matter most in production. Each score reflects real-world trade-offs observed across hundreds of public APIs. No strategy wins on every dimension, which is why the decision tree exists.

Dimension URL Path Query Param Header Content Neg.
DiscoverabilityExcellentGoodPoorPoor
CacheabilityExcellentGoodRequires VaryRequires Vary
URL CleanlinessModeratePoorExcellentExcellent
Routing SimplicitySimpleMiddlewareMiddlewareComplex
Client EffortURL changeParam changeHeader setupAccept header
Testing EaseBrowser/curlBrowser/curlcurl onlycurl only
GranularityEntire APIEntire APIPer-requestPer-resource
AdoptionStripe, TwilioGoogle, AWSAzureGitHub

Migration Complexity Calculator

Estimate the engineering effort to implement or migrate between versioning strategies. Enter your current setup and the calculator produces an effort estimate based on the number of endpoints, clients, and infrastructure components involved.

Migration Complexity Estimator

Code Examples by Strategy

Each tab below shows production-ready code for implementing that versioning strategy in Express.js, Python FastAPI, and nginx. The examples include the routing logic, version extraction, and response headers required for a clean implementation.

// Express.js — URL Path Versioning // Routes: /v1/users, /v2/users const express = require('express'); const app = express(); // V1 handlers const v1Router = express.Router(); v1Router.get('/users', (req, res) => { res.json({ users: [ { id: 1, name: "Alice Chen", email: "alice@example.com" } ] }); }); // V2 handlers (breaking change: nested data envelope) const v2Router = express.Router(); v2Router.get('/users', (req, res) => { res.json({ data: { users: [ { id: 1, full_name: "Alice Chen", email_address: "alice@example.com" } ] }, meta: { total: 1, api_version: "v2" } }); }); app.use('/v1', v1Router); app.use('/v2', v2Router); // Deprecation header on v1 app.use('/v1', (req, res, next) => { res.set('Sunset', 'Sat, 01 Nov 2026 00:00:00 GMT'); res.set('Deprecation', 'true'); res.set('Link', '<https://api.example.com/v2>; rel="successor-version"'); next(); }); # nginx — route by URL prefix location /v1/ { proxy_pass http://api-v1-upstream/; add_header Sunset "Sat, 01 Nov 2026 00:00:00 GMT"; } location /v2/ { proxy_pass http://api-v2-upstream/; }
// Express.js — Query Parameter Versioning // Routes: /users?version=1, /users?version=2 const express = require('express'); const app = express(); function versionMiddleware(req, res, next) { req.apiVersion = parseInt(req.query.version) || 2; // default to latest if (req.apiVersion < 1 || req.apiVersion > 2) { return res.status(400).json({ error: { code: "INVALID_VERSION", message: "Supported: 1, 2" } }); } res.set('X-API-Version', String(req.apiVersion)); next(); } app.use(versionMiddleware); app.get('/users', (req, res) => { if (req.apiVersion === 1) { return res.json({ users: [{ id: 1, name: "Alice" }] }); } // v2 res.json({ data: { users: [{ id: 1, full_name: "Alice Chen" }] }, meta: { api_version: 2 } }); }); # Python FastAPI from fastapi import FastAPI, Query app = FastAPI() @app.get("/users") async def get_users(version: int = Query(default=2, ge=1, le=2)): if version == 1: return {"users": [{"id": 1, "name": "Alice"}]} return { "data": {"users": [{"id": 1, "full_name": "Alice Chen"}]}, "meta": {"api_version": 2} }
// Express.js — Header Versioning // Header: X-API-Version: 2 const express = require('express'); const app = express(); function versionMiddleware(req, res, next) { var header = req.get('X-API-Version') || req.get('Api-Version'); req.apiVersion = parseInt(header) || 2; // default to latest res.set('X-API-Version', String(req.apiVersion)); res.set('Vary', 'X-API-Version'); // critical for caching next(); } app.use(versionMiddleware); app.get('/users', (req, res) => { if (req.apiVersion === 1) { return res.json({ users: [{ id: 1, name: "Alice" }] }); } res.json({ data: { users: [{ id: 1, full_name: "Alice Chen" }] }, meta: { api_version: 2 } }); }); # nginx — route by header map $http_x_api_version $api_backend { default api-v2-upstream; "1" api-v1-upstream; "2" api-v2-upstream; } location /api/ { proxy_pass http://$api_backend/; add_header Vary "X-API-Version"; } # curl example curl -H "X-API-Version: 2" https://api.example.com/users
// Express.js — Content Negotiation // Accept: application/vnd.myapi.v2+json const express = require('express'); const app = express(); function parseAcceptVersion(accept) { if (!accept) return 2; var match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/); return match ? parseInt(match[1]) : 2; } function contentNegMiddleware(req, res, next) { req.apiVersion = parseAcceptVersion(req.get('Accept')); res.set('Content-Type', 'application/vnd.myapi.v' + req.apiVersion + '+json'); res.set('Vary', 'Accept'); next(); } app.use(contentNegMiddleware); app.get('/users', (req, res) => { if (req.apiVersion === 1) { return res.json({ users: [{ id: 1, name: "Alice" }] }); } res.json({ data: { users: [{ id: 1, full_name: "Alice Chen" }] }, meta: { api_version: 2 } }); }); # GitHub-style content negotiation curl -H "Accept: application/vnd.myapi.v2+json" \ https://api.example.com/users # Python FastAPI from fastapi import FastAPI, Request import re app = FastAPI() def get_version(request: Request) -> int: accept = request.headers.get("accept", "") match = re.search(r"vnd\.myapi\.v(\d+)\+json", accept) return int(match.group(1)) if match else 2 @app.get("/users") async def get_users(request: Request): version = get_version(request) if version == 1: return {"users": [{"id": 1, "name": "Alice"}]} return {"data": {"users": [{"id": 1, "full_name": "Alice Chen"}]}}

URL Path Versioning Deep Dive

URL path versioning embeds the version number directly in the URL: /v1/users, /v2/users. It is the most widely adopted strategy for public APIs because it is immediately visible, requires no special client configuration, and works with every HTTP tool from browsers to curl to Postman. Stripe, Twilio, Heroku, and DigitalOcean all use this approach.

The primary advantage is routing simplicity. Each version maps to a separate route prefix, which can point to a completely different codebase, deployment, or even team. There is no middleware to parse headers, no conditional logic in handlers, and no risk of version-detection bugs. The version is right there in the URL where everyone can see it. CDN caching works automatically because /v1/users and /v2/users are different URLs with different cache keys. API documentation is straightforward because each version has its own URL namespace.

The primary disadvantage is URL pollution. Every version adds a prefix to every URL, and clients must update every endpoint URL when migrating. For APIs with 100+ endpoints, this means 100+ URL changes in the client code. Additionally, URL path versioning operates at the API level, not the resource level. You cannot version /users to v2 while keeping /products at v1. When any endpoint needs a breaking change, the entire API moves to the next version, even if 95% of endpoints are unchanged. This leads to "version bloat" where v2 is a copy of v1 with one field renamed on one endpoint.

Header versioning places the version in a custom request header: X-API-Version: 2 or Api-Version: 2. Azure, Shopify, and several enterprise API platforms use this approach. The URL stays clean (/users is always /users), and the version travels as metadata alongside the request rather than as part of the resource identifier.

The key advantage is URL stability. Client code that constructs URLs never changes between versions. Only the HTTP client configuration changes, and that is typically centralized in one place (an API client constructor or an interceptor). This reduces the migration surface from N endpoint URLs to 1 header configuration. Header versioning also supports per-request versioning: a client can send v1 for one endpoint and v2 for another within the same session, enabling gradual migration.

The key disadvantage is discoverability. You cannot test a header-versioned API by typing a URL into a browser. Every request requires custom headers, which means developers must use curl, Postman, or an API client. CDN caching requires the Vary: X-API-Version header, which fragments the cache and reduces hit rates. Documentation must explain the header requirement before a developer can make their first request, adding friction to onboarding. For public APIs where developer experience matters, this friction is a real cost.

Content Negotiation Deep Dive

Content negotiation uses the HTTP Accept header with a vendor media type: Accept: application/vnd.myapi.v2+json. GitHub is the most prominent example of this approach. It is the most HTTP-correct strategy because it uses the existing content negotiation mechanism that HTTP was designed for, treating the API version as a media type variant rather than a resource identifier or arbitrary metadata.

The unique advantage is resource-level granularity. You can version individual resources independently. The /users endpoint can accept vnd.myapi.users.v3+json while /products stays at vnd.myapi.products.v1+json. This eliminates version bloat because each resource evolves at its own pace. It also opens the door to content-type-based transformation: the same endpoint can return JSON, XML, or CSV based on the Accept header, with versioning built into each format.

The disadvantage is complexity. Developers must understand vendor media types, construct Accept headers correctly, and handle 406 Not Acceptable responses when they request an unsupported version. Caching requires Vary: Accept, which fragments the cache significantly since the Accept header has high cardinality. Routing logic must parse the Accept header, extract the version, and map it to the correct handler, which is more complex than checking a URL prefix or a simple header value. For teams without deep HTTP expertise, this complexity creates bugs and confusion.

Deprecation and Sunset Workflow

Versioning is only half the problem. The other half is deprecating old versions without breaking existing clients. The HTTP Sunset header (RFC 8594) and the Deprecation header provide a standard mechanism for communicating version end-of-life. Here is the workflow that minimizes client disruption:

Phase Duration Actions
AnnounceDay 0Add Sunset header to old version. Update docs. Email clients.
Warning0-6 monthsReturn Deprecation: true header. Log clients still on old version.
Grace6-9 monthsAdd warning body to responses. Direct contact with top-traffic clients.
Sunset9-12 monthsReturn 410 Gone with migration link. Preserve for 30 more days.
Removal12+ monthsRemove old version entirely. Redirect to new version docs.
HTTP/1.1 200 OK
Content-Type: application/json
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v2/users>; rel="successor-version"
Link: <https://docs.example.com/migration-v1-to-v2>; rel="deprecation"

{
  "data": { ... },
  "_deprecation": {
    "message": "API v1 will be removed on Nov 1, 2026. Migrate to v2.",
    "migration_guide": "https://docs.example.com/migration-v1-to-v2",
    "successor": "https://api.example.com/v2/users"
  }
}

For testing how your clients handle deprecated API responses, use Kappafy's mock API generator to simulate Sunset and Deprecation headers. Combine versioning with circuit breaker patterns to gracefully fall back when a version is sunset unexpectedly. For latency implications of running multiple API versions simultaneously, the latency simulator can model the overhead.

Frequently Asked Questions

What is the best API versioning strategy?

There is no single best strategy. URL path versioning (e.g., /v1/users) is the best default for public APIs because it is explicit, cacheable, and requires zero client configuration. Header versioning is better for internal APIs where you control all clients and want cleaner URLs. Content negotiation is best for APIs that need fine-grained resource-level versioning. Use the decision tree above based on your specific constraints: audience (public vs internal), number of clients, breaking change frequency, and caching requirements.

When should I create a new API version?

Create a new version only for breaking changes: removing a field, changing a field's type, renaming a field, changing authentication requirements, or altering the structure of the response envelope. Additive changes like new optional fields, new endpoints, or new query parameters are backward-compatible and do not require a new version. The rule is simple: if existing clients will break without code changes, it is a breaking change that requires a new version. Avoid versioning for cosmetic changes or internal refactors that do not affect the contract.

How do I migrate clients between API versions?

Run both versions simultaneously for a migration window of 6-12 months for public APIs. Send deprecation headers (Sunset, Deprecation) on the old version to alert clients programmatically. Monitor traffic on both versions and directly contact the highest-traffic clients still using the old version. Provide a comprehensive migration guide documenting every breaking change with before and after examples. Set a hard sunset date and return 410 Gone after the deadline. Never remove the old version without at least 3 months of deprecation warnings in the response headers.

What is content negotiation for API versioning?

Content negotiation uses the Accept header to specify the API version along with the media type, for example Accept: application/vnd.myapi.v2+json. This approach keeps URLs clean, allows versioning at the resource level rather than the entire API, and follows HTTP semantics precisely. The downside is that it is harder to test (you need custom headers for every request), not cacheable by URL alone (requires Vary: Accept), and unfamiliar to many developers. GitHub is the most prominent API using this approach for their REST API.

How many API versions should I maintain simultaneously?

Maintain at most 2-3 versions simultaneously. Each active version multiplies your testing surface, documentation burden, and bug-fix effort by the number of active versions. The current version receives new features and bug fixes. The previous version receives security patches only. Any version older than N-1 should be actively sunset. For public APIs, plan 12-month version lifecycles with 6-month overlap periods. For internal APIs, 3-6 months is sufficient since you control all clients and can coordinate upgrades.

ML

Michael Lip

Solo developer building free tools at Zovo. Kappafy helps developers work with JSON and APIs faster. No tracking, no accounts, no data collection. Learn more.