Introduction
APIs are contracts between services and clients. As your application evolves, you need to make changes without breaking existing integrations. API versioning provides a way to introduce changes while maintaining backward compatibility.
This article explores different versioning strategies, when to use each, implementation patterns, and best practices for API evolution.
Why API Versioning Matters
┌─────────────────────────────────────────────────────────────────┐
│ API Versioning Challenges │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Changes that require versioning: │
│ - Removing or renaming fields │
│ - Changing field types │
│ - Changing response structure │
│ - Adding required fields │
│ - Removing endpoints │
│ - Changing authentication requirements │
│ │
│ Without versioning: │
│ │
│ Client v1 Server updates Client v1 │
│ ────────── ─────────────────────── ───────────── │
│ │ │ │ │
│ │ Deploy │ │ │
│ │──────────────│ │ │
│ │ │ │ │
│ │ Response │ │ │
│ │ changed! │ │ │
│ │◀─────────────│ │ │
│ │ │ │ │
│ │ │ │ 🔴 BROKEN! │
│ │
│ With versioning: │
│ │
│ Client v1 ──▶ /api/v1/users ──▶ Server maintains v1 │
│ Client v2 ──▶ /api/v2/users ──▶ Server returns v2 │
│ │
│ Both clients work! ✅ │
│ │
└─────────────────────────────────────────────────────────────────┘
Versioning Strategies
1. URI Path Versioning
┌─────────────────────────────────────────────────────────────────┐
│ URI Path Versioning │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GET /api/v1/users GET /api/v2/users │
│ GET /api/v1/users/123 GET /api/v2/users/123 │
│ POST /api/v1/users POST /api/v2/users │
│ │
│ Pros: │
│ - Simple to implement │
│ - Easy to route and manage │
│ - Visible in URL │
│ - Easy to cache │
│ │
│ Cons: │
│ - URL pollution │
│ - Duplicate code for similar endpoints │
│ - Changes affect all clients │
│ │
└─────────────────────────────────────────────────────────────────┘
# FastAPI URI versioning
from fastapi import FastAPI, APIRouter, HTTPException
from typing import Optional
from datetime import datetime
from pydantic import BaseModel
app = FastAPI()
# Version routers
v1_router = APIRouter(prefix="/api/v1")
v2_router = APIRouter(prefix="/api/v2")
# Models
class UserV1(BaseModel):
id: int
name: str
email: str
created_at: str
class UserV2(BaseModel):
id: int
name: str
email: str
profile: Optional[dict] = None
status: str = "active"
created_at: str
updated_at: str
# Mock database
users_db = {
1: {"id": 1, "name": "John", "email": "[email protected]", "created_at": "2024-01-01"},
2: {"id": 2, "name": "Jane", "email": "[email protected]", "created_at": "2024-01-02"},
}
# v1 Endpoints
@v1_router.get("/users", response_model=list[UserV1])
async def get_users_v1():
return list(users_db.values())
@v1_router.get("/users/{user_id}", response_model=UserV1)
async def get_user_v1(user_id: int):
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
return users_db[user_id]
@v1_router.post("/users", response_model=UserV1)
async def create_user_v1(user: UserV1):
new_id = max(users_db.keys()) + 1
user_data = user.dict()
user_data["id"] = new_id
users_db[new_id] = user_data
return user_data
# v2 Endpoints
@v2_router.get("/users", response_model=list[UserV2])
async def get_users_v2(status: Optional[str] = None):
users = []
for user in users_db.values():
user_data = {
**user,
"profile": {"bio": "User bio", "avatar": f"/avatars/{user['id']}.jpg"},
"status": "active",
"updated_at": datetime.utcnow().isoformat(),
}
if status and user_data["status"] != status:
continue
users.append(user_data)
return users
@v2_router.get("/users/{user_id}", response_model=UserV2)
async def get_user_v2(user_id: int):
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
user = users_db[user_id]
return {
**user,
"profile": {"bio": "User bio", "avatar": f"/avatars/{user['id']}.jpg"},
"status": "active",
"updated_at": datetime.utcnow().isoformat(),
}
@v2_router.post("/users", response_model=UserV2)
async def create_user_v2(user: UserV2):
new_id = max(users_db.keys()) + 1
now = datetime.utcnow().isoformat()
user_data = {
"id": new_id,
"name": user.name,
"email": user.email,
"created_at": now,
"profile": user.profile or {},
"status": user.status,
"updated_at": now,
}
users_db[new_id] = user_data
return user_data
# Register routers
app.include_router(v1_router)
app.include_router(v2_router)
2. Header-Based Versioning
┌─────────────────────────────────────────────────────────────────┐
│ Header-Based Versioning │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GET /api/users headers: │
│ Accept: application/json │
│ Accept-Version: v1 │
│ │
│ GET /api/users headers: │
│ Accept: application/json │
│ Accept-Version: v2 │
│ │
│ Alternative: │
│ X-API-Version: v1 │
│ │
│ Pros: │
│ - Clean URLs │
│ - Can version individual resources │
│ - Flexible │
│ │
│ Cons: │
│ - Less visible │
│ - Harder to test │
│ - Cache complexity │
│ │
└─────────────────────────────────────────────────────────────────┘
from fastapi import FastAPI, Header, HTTPException
from typing import Optional
app = FastAPI()
async def get_api_version(x_api_version: Optional[str] = Header(None)):
"""Extract API version from header."""
return x_api_version or "v1"
@app.get("/users")
async def get_users(
x_api_version: Optional[str] = Header(None, alias="X-API-Version"),
):
"""Get users with header-based versioning."""
version = x_api_version or "v1"
if version == "v1":
return await get_users_v1()
elif version == "v2":
return await get_users_v2()
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {version}"
)
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
x_api_version: Optional[str] = Header(None),
):
"""Get user by ID with header-based versioning."""
version = x_api_version or "v1"
if version == "v1":
return await get_user_v1(user_id)
elif version == "v2":
return await get_user_v2(user_id)
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {version}"
)
3. Query Parameter Versioning
┌─────────────────────────────────────────────────────────────────┐
│ Query Parameter Versioning │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GET /api/users?version=1 │
│ GET /api/users?version=2 │
│ GET /api/users?api_version=v2 │
│ │
│ Pros: │
│ - Easy to implement │
│ - Clients can choose version easily │
│ - Can be optional (default to latest) │
│ │
│ Cons: │
│ - Can lead to complex URLs │
│ - Not RESTful (version is state, not resource) │
│ - Caching issues │
│ │
└─────────────────────────────────────────────────────────────────┘
@app.get("/users")
async def get_users(
version: Optional[str] = Query("v1", regex="^(v1|v2)$"),
):
"""Get users with query parameter versioning."""
if version == "v1":
return await get_users_v1()
elif version == "v2":
return await get_users_v2()
4. Content Negotiation (Media Type)
┌─────────────────────────────────────────────────────────────────┐
│ Content Negotiation Versioning │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GET /api/users │
│ Accept: application/vnd.myapp.v1+json │
│ │
│ GET /api/users │
│ Accept: application/vnd.myapp.v2+json │
│ │
│ Response: │
│ Content-Type: application/vnd.myapp.v2+json │
│ │
│ Pros: │
│ - True RESTful approach │
│ - Standard HTTP content negotiation │
│ - Version per resource │
│ │
│ Cons: │
│ - Complex to implement │
│ - Harder for clients │
│ - Less visible │
│ │
└─────────────────────────────────────────────────────────────────┘
from fastapi import FastAPI
from fastapi.responses import JSONResponse
@app.get("/users", responses={
200: {
"content": {
"application/vnd.myapp.v1+json": {"schema": UserV1Schema},
"application/vnd.myapp.v2+json": {"schema": UserV2Schema},
}
}
})
async def get_users(accept: str = Header(None)):
"""Get users with content negotiation."""
# Parse Accept header
version = "v1"
if accept:
if "v2" in accept:
version = "v2"
if version == "v1":
return JSONResponse(
content=await get_users_v1(),
media_type="application/vnd.myapp.v1+json"
)
else:
return JSONResponse(
content=await get_users_v2(),
media_type="application/vnd.myapp.v2+json"
)
Versioning Best Practices
Version Strategy Comparison
| Strategy | Visibility | RESTful | Caching | Complexity |
|---|---|---|---|---|
| URI Path | High | Medium | Easy | Low |
| Header | Low | High | Medium | Medium |
| Query Param | Medium | Low | Medium | Low |
| Media Type | Low | High | Medium | High |
Recommended Approach
class VersionedAPIRouter:
"""Centralized version management."""
VERSIONS = {
"v1": {
"deprecated": True,
"sunset_date": "2026-06-01",
"deprecation_notice": "v1 will be removed",
},
"v2": {
"deprecated": False,
"sunset_date": None,
}
}
def __init__(self, default_version: str = "v2"):
self.default_version = default_version
self.routers = {}
def register_version(self, version: str, router):
"""Register a versioned router."""
self.routers[version] = router
async def route_request(self, request):
"""Route request to appropriate version."""
# Extract version from URL path
path = request.url.path
version = self.default_version
for v in self.VERSIONS:
if f"/api/{v}/" in path:
version = v
break
# Check if version exists
if version not in self.routers:
raise HTTPException(
status_code=404,
detail=f"API version {version} not found"
)
# Check deprecation
version_info = self.VERSIONS.get(version, {})
if version_info.get("deprecated"):
request.state.deprecation_warning = version_info.get(
"deprecation_notice",
f"API version {version} is deprecated"
)
return await self.routers[version].dispatch(request)
Deprecation Strategy
Sunset Headers
from fastapi import Response
@app.middleware("http")
async def add_deprecation_headers(request, call_next):
"""Add deprecation headers to responses."""
response = await call_next(request)
# Check if version is deprecated
version = extract_version(request.url.path)
version_info = VersionedAPIRouter.VERSIONS.get(version, {})
if version_info.get("deprecated"):
response.headers["Deprecation"] = "true"
if version_info.get("sunset_date"):
response.headers["Sunset"] = version_info["sunset_date"]
if version_info.get("deprecation_notice"):
response.headers["Link"] = f'<{version_info.get("docs_url")}>; rel="deprecation"'
return response
Deprecation Response
{
"warning": "API version v1 is deprecated",
"deprecated_at": "2026-01-01",
"sunset_date": "2026-06-01",
"message": "Please migrate to v2. Documentation: https://api.example.com/docs/v2",
"migration_guide": "https://api.example.com/docs/migration/v1-to-v2"
}
Versioning with API Gateway
# API Gateway configuration (Kong)
services:
- name: user-service
url: http://user-service:8000
routes:
- name: users-v1
paths:
- /api/v1/users
strip_path: false
plugins:
- name: rate-limiting
config:
minute: 100
- name: request-transformer
config:
add:
headers:
- "X-API-Version:v1"
- name: users-v2
paths:
- /api/v2/users
strip_path: false
plugins:
- name: rate-limiting
config:
minute: 1000
- name: request-transformer
config:
add:
headers:
- "X-API-Version:v2"
# AWS API Gateway
openapi: 3.0.0
info:
title: User API
servers:
- url: https://api.example.com/v1
description: Version 1 (deprecated)
- url: https://api.example.com/v2
description: Version 2 (current)
paths:
/users:
get:
summary: Get users
operationId: getUsers
responses:
'200':
description: List of users
content:
application/json:
schema:
$ref: '#/components/schemas/Users'
Client Version Handling
// API Client with automatic versioning
class APIClient {
constructor(baseUrl, defaultVersion = 'v2') {
this.baseUrl = baseUrl;
this.defaultVersion = defaultVersion;
this.version = defaultVersion;
}
setVersion(version) {
this.version = version;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}/api/${this.version}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Accept': `application/vnd.myapp.${this.version}+json`,
...options.headers,
}
});
// Handle deprecation warnings
if (response.headers.get('Deprecation')) {
console.warn(
`API version ${this.version} is deprecated. ` +
`Sunset date: ${response.headers.get('Sunset')}`
);
}
return response.json();
}
// Convenience methods
async getUsers() {
return this.request('/users');
}
async getUser(id) {
return this.request(`/users/${id}`);
}
}
// Usage
const api = new APIClient('https://api.example.com');
// Use specific version
const v1Users = await api.setVersion('v1').getUsers();
const v2Users = await api.setVersion('v2').getUsers();
Migration Strategies
Gradual Migration
┌─────────────────────────────────────────────────────────────────┐
│ Gradual Migration Strategy │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: Deploy v2 │
│ - Run v1 and v2 simultaneously │
│ - Monitor v2 usage │
│ - 90% v1, 10% v2 │
│ │
│ Phase 2: Encourage migration │
│ - Add deprecation warnings to v1 responses │
│ - Update documentation │
│ - Notify major API consumers │
│ - 60% v1, 40% v2 │
│ │
│ Phase 3: Deprecate v1 │
│ - Set sunset date │
│ - Return deprecation headers │
│ - 20% v1, 80% v2 │
│ │
│ Phase 4: Sunset v1 │
│ - Remove v1 after sunset date │
│ - Keep v1 available for critical clients (if needed) │
│ - 0% v1, 100% v2 │
│ │
└─────────────────────────────────────────────────────────────────┘
class VersionMigrationManager:
"""Manage API version migration."""
def __init__(self):
self.migrations = {
"v1": {
"status": "deprecated",
"sunset_date": "2026-06-01",
"migration_guide": "/docs/migration/v1-to-v2",
"supported_until": "2026-06-01",
}
}
def check_version_status(self, version: str) -> dict:
"""Check status of API version."""
info = self.migrations.get(version, {})
if not info:
return {
"valid": True,
"status": "current"
}
return {
"valid": True,
"status": info.get("status", "current"),
"sunset_date": info.get("sunset_date"),
"migration_guide": info.get("migration_guide"),
}
def handle_deprecated_request(self, version: str) -> dict:
"""Handle requests to deprecated versions."""
info = self.migrations.get(version, {})
return {
"warning": f"API version {version} is deprecated",
"sunset_date": info.get("sunset_date"),
"migration_guide": info.get("migration_guide"),
"supported_until": info.get("supported_until"),
}
Version Compatibility
Semantic Versioning for APIs
class APIVersion:
"""Semantic versioning for APIs."""
def __init__(self, major: int, minor: int = 0, patch: int = 0):
self.major = major
self.minor = minor
self.patch = patch
def __str__(self):
return f"v{self.major}.{self.minor}.{self.patch}"
def is_compatible_with(self, other: 'APIVersion') -> bool:
"""Check if versions are compatible."""
# Same major version = backward compatible
return self.major == other.major
def breaking_change_from(self, other: 'APIVersion') -> bool:
"""Check if this version has breaking changes."""
return self.major > other.major
# Version compatibility matrix
COMPATIBILITY = {
("v1", "v1"): "compatible",
("v1", "v2"): "breaking", # Major version change
("v2", "v2"): "compatible",
("v2", "v3"): "compatible", # Minor version change
}
Conclusion
API versioning is essential for evolving your APIs without breaking existing clients. The choice of strategy depends on your specific needs: URI path versioning for simplicity, header-based for clean URLs, or content negotiation for true RESTful design.
Key takeaways:
- Choose a versioning strategy early and document it
- Implement deprecation headers and notices
- Provide clear migration paths
- Support multiple versions during transition periods
- Monitor version usage and plan sunset carefully
Resources
- RFC 5829 - API Versioning Hints
- Microsoft API Versioning
- Stripe API Versioning
- GitHub API Versioning
Comments