API versioning strategies — picking the right approach
API versioning is one of those decisions you make once and live with for years. Pick the wrong strategy and you end up with a proliferation of dead versions in your codebase, confused consumers, or both. There is no universally correct answer — but there are clear tradeoffs, and the right choice depends on who your consumers are and how they deploy.
Strategy 1: URL path versioning
/v1/users, /v2/users — the most common and most visible approach.
# FastAPI example
from fastapi import APIRouter
v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")
@v1_router.get("/users/{id}")
def get_user_v1(id: str):
# Old response shape
return {"user_id": id, "name": "Alice"}
@v2_router.get("/users/{id}")
def get_user_v2(id: str):
# New response shape with additional fields
return {
"id": id, # renamed from user_id
"name": "Alice",
"created_at": "2026-01-01T00:00:00Z",
"metadata": {},
}
app.include_router(v1_router)
app.include_router(v2_router)
Pros: Explicit, cache-friendly (URLs are different), easy to route at the load balancer level, easy to sunset (delete the route).
Cons: Versioning is "all or nothing" — if you change one field in one endpoint, you version the entire API. Can lead to duplication.
Best for: Public APIs with external consumers who deploy on their own schedule. Mobile apps that cannot be force-updated.
Strategy 2: Header versioning
The version is passed in a request header: API-Version: 2 or Accept: application/vnd.myapi.v2+json.
# FastAPI with header versioning
from fastapi import Header, HTTPException
@app.get("/users/{id}")
def get_user(
id: str,
api_version: str = Header(default="1", alias="API-Version"),
):
if api_version == "2":
return get_user_v2_response(id)
elif api_version == "1":
return get_user_v1_response(id)
else:
raise HTTPException(400, f"Unsupported API version: {api_version}")
Pros: Clean URLs (version does not pollute the path), same URL works across versions.
Cons: Not cache-friendly (same URL, different responses), harder to test in browser/curl, consumers must remember to set the header.
Best for: Internal APIs consumed by services you control, where you can enforce header usage.
Strategy 3: Content negotiation (Accept header)
# Accept: application/vnd.myapi+json; version=2
@app.get("/users/{id}")
def get_user(id: str, accept: str = Header(default="")):
version = parse_version_from_accept(accept) # extract version=2 from Accept header
if version == 2:
return get_user_v2_response(id)
return get_user_v1_response(id)
def parse_version_from_accept(accept: str) -> int:
import re
match = re.search(r'version=(d+)', accept)
return int(match.group(1)) if match else 1
Pros: Standards-compliant, elegant for resource-oriented APIs.
Cons: Verbose, unfamiliar to many developers, hard to test without proper HTTP clients.
Best for: APIs that strictly follow REST principles and have technically sophisticated consumers.
Strategy 4: Field-level versioning with deprecation flags
Instead of versioning the API, deprecate individual fields and add new ones. This avoids the overhead of maintaining parallel route implementations:
# Response includes old and new fields during transition period
def get_user_response(user: User) -> dict:
return {
"id": user.id,
"name": user.name,
# Deprecated: will be removed in 6 months
"user_id": user.id, # kept for backward compatibility
# New field
"created_at": user.created_at.isoformat(),
}
# Set a deprecation header
@app.get("/users/{id}")
def get_user(id: str, response: Response):
user = get_user_by_id(id)
if uses_deprecated_field(request):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Sat, 01 Jan 2027 00:00:00 GMT"
return get_user_response(user)
Pros: No parallel route maintenance, consumers migrate at their own pace, granular deprecation.
Cons: Response bloat during transition, requires discipline to remove deprecated fields on schedule.
Best for: APIs with well-behaved internal consumers who you can notify and track.
Choosing the right strategy
| Consumer type | Recommended strategy |
|---|---|
| Public third-party developers | URL path versioning |
| Mobile apps you do not control | URL path versioning |
| Internal services | Header versioning or field deprecation |
| Same-team frontend | Field deprecation with short timelines |
| Partner integrations with SLAs | URL path versioning with formal sunset dates |
Version sunset policy
The most important policy to establish before launching v1:
# API Version Lifecycle Policy
- Each major version is supported for **18 months** after the next major version launches
- 6 months notice before sunset (Sunset header + email to registered consumers)
- Traffic monitoring: if >1% of traffic still on deprecated version at sunset date, extend by 3 months
- No minimum support for beta/preview versions
Without a sunset policy, old versions live forever. With one, you can confidently delete code and reduce maintenance burden on a schedule.
The one thing most teams get wrong
Starting with v2 "for flexibility." Do not. Start with a versionless API (/users, not /v1/users). When you need to break compatibility, that becomes your v1 and you introduce v2. Starting at v1 immediately signals to consumers that breaking changes are coming — which increases their anxiety and often leads to premature migration.