How to Implement API Versioning: URL vs Header Strategies
Prerequisites
- A REST API (any framework)
- Understanding of HTTP methods and status codes
Why Version Your API
APIs evolve. Fields get renamed, endpoints change behaviour, response formats shift. Without versioning, any change breaks every client. Versioning lets you release breaking changes without disrupting existing consumers.
The goal: clients opt into new versions on their schedule, not yours.
Strategy 1: URL Path Versioning
The most common and visible approach:
GET /api/v1/users
GET /api/v2/users
Implementation (FastAPI example):
# v1 router
from fastapi import APIRouter
v1 = APIRouter(prefix="/api/v1")
@v1.get("/users")
def get_users_v1():
return {"version": "v1", "users": [{"name": "John"}]}
# v2 router
v2 = APIRouter(prefix="/api/v2")
@v2.get("/users")
def get_users_v2():
return {"version": "v2", "users": [{"first_name": "John", "last_name": "Doe"}]}
app.include_router(v1)
app.include_router(v2)
Pros: Simple, visible in logs, easy to route via reverse proxy Cons: URL clutter, requires duplicate route registrations, clients must change URLs
Strategy 2: Header-Based Versioning
Version is specified in a custom header:
GET /api/users
Accept-Version: v2
Or using content negotiation:
GET /api/users
Accept: application/vnd.myapp.v2+json
Implementation:
from fastapi import Header, HTTPException
@app.get("/users")
def get_users(api_version: str = Header(default="v1")):
if api_version == "v1":
return {"version": "v1", "name": "John"}
elif api_version == "v2":
return {"version": "v2", "first_name": "John", "last_name": "Doe"}
raise HTTPException(400, f"Unknown version: {api_version}")
Pros: Clean URLs, no route duplication Cons: Harder to test in browser, caching proxies may not vary by custom header
Strategy 3: Query Parameter Versioning
GET /api/users?version=2
Pros: Easiest to test in browser Cons: Pollutes query parameters, easy for clients to forget, doesn’t feel RESTful
Deprecation Strategy
Never remove old versions without warning. The lifecycle:
- Announce — email, changelog, API status page
- Warn in response headers — add deprecation notices
Deprecation: true
Sunset: Sat, 01 Aug 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
- Warn in response body — include a
deprecation_warningfield - Monitor usage — track how many requests still hit old versions
- Remove — only when usage drops to near-zero
Implementation:
@app.middleware("http")
async def deprecation_header(request, call_next):
response = await call_next(request)
if "/v1/" in request.url.path:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Sat, 01 Aug 2026 00:00:00 GMT"
return response
Migration Strategy
Help clients migrate:
- Provide a migration guide documenting every breaking change
- Offer a compatibility mode — v2 endpoint with v1 response shape via query param
?compat=v1 - Run both versions concurrently during the deprecation window
- Set up automated tests against each active version
Which Strategy to Choose?
| Strategy | Best for |
|---|---|
| URL path | Public APIs, third-party integrations, simple caching |
| Header | Internal APIs, microservices, clean URLs |
| Query param | Quick experiments, internal tools |
Recommendation: Use URL path versioning for public APIs. It’s the most transparent and debug-friendly. Reserve major version bumps (v1 → v2) for breaking changes. Don’t version for additive changes — just add fields.
FAQ
Q: How many versions should I maintain? At most 2 active versions (current + previous). Sunset old ones aggressively.
Q: Do I need to version if I only add fields? No. Adding optional fields is backward-compatible. Only version when you remove, rename, or change the type/semantics of existing fields.
Q: Can I skip v1 and start at v2? Don’t. It confuses everyone. Start at v1.