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