API Versioning: URL, Header & Media Type Strategies
How to version REST APIs with URL path, header, and media-type strategies while keeping backward compatibility for existing clients.
Overview
API versioning keeps existing clients working while an API evolves. When a field is renamed, a response changes shape, or auth requirements tighten, clients on the old contract break unless versions run side by side. This recipe covers the three dominant approaches — URL path, custom header, and content negotiation (media type) — as middleware in Flask, Express, and Spring Boot, plus the deprecation mechanics that let you retire versions safely. For the full strategy comparison, see the Complete Guide to API Versioning Strategies.
When to Use
Use this resource when:
- Publishing a public API consumed by external clients you can’t update simultaneously
- Introducing breaking changes such as removed fields, renamed resources, or new auth requirements
- Supporting several client generations: mobile apps, third-party integrations, embedded widgets
- Planning a gradual migration from a legacy API shape to a modern design. See Call REST API for client-side patterns
Skip versioning when you control both the API and its only consumer: deploy breaking changes atomically instead of carrying version indirection.
Solution
Python (Flask + URL Path Versioning)
from flask import Flask, jsonify, request
app = Flask(__name__)
# Versioned blueprint registration
from v1 import users as users_v1
from v2 import users as users_v2
app.register_blueprint(users_v1.bp, url_prefix="/api/v1/users")
app.register_blueprint(users_v2.bp, url_prefix="/api/v2/users")
# Header-based alternative
@app.before_request
def version_from_header():
version = request.headers.get("X-API-Version", "v1")
request.api_version = version
@app.route("/api/users/<int:id>")
def get_user(id):
if request.api_version == "v2":
return jsonify({"id": id, "full_name": "Alice", "email": "alice@example.com"})
return jsonify({"id": id, "name": "Alice"})
# Content negotiation (Accept header)
@app.route("/api/users")
def list_users():
accept = request.headers.get("Accept", "")
if "application/vnd.myapp.v2+json" in accept:
return jsonify({"users": [{"full_name": "Alice"}]})
return jsonify({"users": [{"name": "Alice"}]})
JavaScript (Express + URL Path Versioning)
import express from "express";
const app = express();
// Route-level versioning
app.use("/api/v1/users", (await import("./routes/v1/users.js")).default);
app.use("/api/v2/users", (await import("./routes/v2/users.js")).default);
// Middleware-based header versioning
function apiVersion(req, res, next) {
req.apiVersion = req.headers["x-api-version"] || "v1";
next();
}
app.get("/api/users/:id", apiVersion, (req, res) => {
if (req.apiVersion === "v2") {
return res.json({ id: req.params.id, full_name: "Alice", email: "alice@example.com" });
}
res.json({ id: req.params.id, name: "Alice" });
});
// Content negotiation
app.get("/api/users", (req, res) => {
const accept = req.get("Accept") || "";
if (accept.includes("application/vnd.myapp.v2+json")) {
return res.json({ users: [{ full_name: "Alice" }] });
}
res.json({ users: [{ name: "Alice" }] });
});
// Query parameter variant — fine for prototypes, weaker for caching
app.get("/api/items", (req, res) => {
const version = req.query.version === "v2" ? "v2" : "v1";
res.json({ version, items: [] });
});
Java (Spring Boot + URL Path & Header)
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
@RestController
public class UserController {
// URL path versioning
@GetMapping("/api/v1/users/{id}")
public UserV1 getUserV1(@PathVariable Long id) {
return new UserV1(id, "Alice");
}
@GetMapping("/api/v2/users/{id}")
public UserV2 getUserV2(@PathVariable Long id) {
return new UserV2(id, "Alice", "alice@example.com");
}
// Header versioning
@GetMapping(value = "/api/users/{id}", headers = "X-API-Version=v1")
public UserV1 getUserHeaderV1(@PathVariable Long id) {
return getUserV1(id);
}
@GetMapping(value = "/api/users/{id}", headers = "X-API-Version=v2")
public UserV2 getUserHeaderV2(@PathVariable Long id) {
return getUserV2(id);
}
// Content negotiation (produces)
@GetMapping(value = "/api/users/{id}", produces = "application/vnd.myapp.v2+json")
public UserV2 getUserMediaV2(@PathVariable Long id) {
return getUserV2(id);
}
}
record UserV1(Long id, String name) {}
record UserV2(Long id, String fullName, String email) {}
Explanation
- URL path versioning (
/v1/,/v2/) is the simplest and most cache-friendly approach. It is visible, easy to document, and works with every HTTP client. The trade-off: it pollutes the URL space and forces clients to change URLs on every breaking update. - Header versioning (
X-API-Version: v2) keeps URLs clean but requires custom headers, which browsers and simple scripts don’t always handle well. Caching at the CDN level needs explicit rules. - Content negotiation (
Accept: application/vnd.myapp.v2+json) is the most RESTful approach. It uses standard HTTP machinery but is harder for consumers and mixes poorly with plainapplication/jsonexpectations. - Backward compatibility means additive-only changes inside a version: new optional fields, new endpoints, and expanded enums are safe. Removing or renaming fields requires a new version.
- Deprecation works best when it’s machine-readable: return
DeprecationandSunsetheaders (see API Deprecation Notice Template) so clients can detect deadlines without reading docs. - Caching differs per strategy: path versioning gives each version its own cache key, while header and media-type versioning need a
Varyheader that fragments cache entries per client.
Variants
| Strategy | Mechanism | Best For |
|---|---|---|
| URL Path | /api/v1/resource | Public APIs, simple caching, broad client support |
| Custom Header | X-API-Version: v2 | Internal APIs, clean URLs, CDN-aware routing |
| Media Type | Accept: application/vnd.app.v2+json | Strict RESTful design, content-driven APIs |
| Query Parameter | ?version=v2 | Prototyping, simplest client implementation |
Best Practices
- Start with URL path versioning: it’s the most discoverable and needs no special client logic.
- Never break a published version: once live, maintain it until a published sunset date.
- Version only on breaking changes: additive changes (new optional fields, new endpoints) don’t need a version bump.
- Cap active versions: each extra version multiplies routing, testing, documentation, and deployment surface. Two active versions plus one in sunset is a workable ceiling for most teams.
- Version the response schema, not just the routes: payloads can drift even under the same URL.
- Use semantic versions internally, major-only publicly:
v1.3.2for packages and internal tracking,/v1/for the public surface. - Publish a migration guide per major version: side-by-side examples of old vs new behavior cut support tickets during transitions.
- Signal deprecation in headers:
Deprecation: trueandSunset: <HTTP-date>give programmatic clients visibility into deadlines without reading docs.
Common Mistakes
- Bumping the version for every minor change, fragmenting the client ecosystem. See Input Validation for additive changes that stay compatible.
- Removing old versions without a sunset period, breaking production integrations overnight.
- Mixing versioning strategies across endpoints in the same API: version the whole surface, not individual endpoints.
- Not normalizing version identifiers, so
v1.0andv1accidentally route to different handlers. - Returning different error shapes or status codes across versions without documenting them.
- Keeping three or more versions alive indefinitely: each one carries its own controllers, serializers, tests, and monitoring.
Troubleshooting
- 404 on a versioned route: the version isn’t registered in the gateway or router. Confirm the route table covers every active version.
- Clients still hitting a sunset version: verify
DeprecationandSunsetheaders actually reach them, then check per-version traffic before enforcing410 Gone. - Stale responses after publishing a new version: the CDN cached the old payload. Header and media-type versioning need a
Varyheader and distinct cache keys; purge the affected entries. v1.0andv1treated as different versions: normalize the version identifier at ingress and reject values outside the supported set.- Browser clients ignoring the version header: browsers can’t always attach custom headers to cross-origin requests. Serve a path or query-param fallback for browser-facing endpoints.
Testing Strategy
- Version routing: request
/v1/and/v2/endpoints and confirm they reach the correct handlers. - Deprecation headers: call a deprecated version and verify the response carries
Deprecation,Sunset, andLinkheaders. - Backward compatibility: run the v1 test suite against v2; every v1 test should pass unless the change was intentional.
- Sunset behavior: simulate the sunset date and confirm the API returns
410 Gonewith a pointer to the current version. - Breaking-change detection: run
oasdifforopenapi-diffin CI to flag contract changes between versions before deploy.
Monitoring
- Traffic share per version: when v2 overtakes v1, plan the v1 sunset. When a version sits below ~1% of traffic for 30 days, it’s a decommission candidate.
- Deprecation header impressions: count how many clients still receive
DeprecationandSunsetheaders; that’s your migration backlog. - 404s on unknown versions: requests for
/v3/when only v1-v2 exist mean misconfigured clients. Log the requested version and client ID so you can reach out. - Latency per version: newer versions should be as fast or faster; if v2 regresses, look at new fields and queries before blaming routing.
When Not to Use
- Single-consumer internal APIs: if you control both sides, deploy breaking changes atomically. Versioning adds indirection you don’t need.
- Prototype and MVP APIs: formal versioning slows iteration while the contract still changes weekly.
- GraphQL APIs: GraphQL evolves through additive schema changes and
@deprecatedfields, not URL versions.
Security Considerations
- Validate version headers: with header-based versioning, accept only known values and reject everything else: an unvalidated
X-API-Versionheader is an injection and routing-confusion vector. - Keep error shapes identical across versions: divergent error payloads let an attacker fingerprint which stack version answers a request.
See Also
- RFC 8594 — The Sunset HTTP Header Field
- RFC 9110 — HTTP Semantics: the
Varyheader - The Deprecation HTTP Header Field (IETF draft)
- API Changelog Template for documenting what changed per version
Frequently Asked Questions
When should I release a new API version?
Only for breaking changes: removed fields, renamed resources, changed auth requirements, or altered behavior that existing clients depend on. Additive changes (new optional fields, new endpoints) don't require a version bump.
Should I use URL path or header versioning?
Use URL path (/v1/) for public APIs: it's visible, trivially cacheable, and works in browsers, curl, and proxies. Header or media-type versioning fits internal APIs where you control the clients and value clean URLs.
Can I support multiple versions with the same codebase?
Yes. Use versioned controllers or route handlers that delegate to shared services. Keep version-specific logic thin (serialization, validation) and business logic version-agnostic. Spring Boot, Express, and Flask all support this pattern cleanly.
How do Deprecation and Sunset headers work?
Deprecation marks an endpoint as deprecated and Sunset: <HTTP-date> announces when it stops working, so clients can detect deadlines programmatically instead of reading changelogs. See the API Deprecation Notice Template for the communication side.
How long should I keep an old API version alive?
For public APIs: 12-24 months with active deprecation notices. For internal APIs: 3-6 months or until all known clients migrate. Always monitor traffic to old versions and contact active users before enforcing sunset.
Related Resources
Complete Guide to API Versioning Strategies
Version REST and GraphQL APIs with URI, header, query param, and content negotiation strategies. Covers deprecation, sunset, and migration patterns.
DocAPI Deprecation Notice Template
A template for communicating API deprecations, breaking changes, and sunset timelines to consumers.
DocAPI Changelog Template
A template for documenting API changes including breaking changes, new features, deprecations, and bug fixes.
RecipeOpenAPI 3.1 API Docs with Swagger UI and Redoc
A practical guide to documenting REST APIs with OpenAPI. Generate interactive Swagger UI and Redoc docs in Python, JavaScript and Java with CI linting.
RecipeIdempotent API Endpoints
How to design and implement idempotent API endpoints that safely handle retries, duplicate requests, and network failures without side effects.
RecipeHandle API Errors with RFC 7807 and HTTP Status Codes
Patterns for consistent, predictable API error handling across multiple languages and frameworks.