APIs are contracts with your users. When you need to change them, you have options. This guide covers versioning strategies, their trade-offs, and how to manage breaking changes without hurting your users.
Why Version?
APIs evolve. Features get added, behaviors change, fields get renamed. Without versioning, every change could break existing clients. Versioning lets you:
- Add features without breaking existing clients
- Maintain backward compatibility
- Give users time to migrate
- Support multiple client versions simultaneously
Versioning Strategies
1. URL Path Versioning
Most common approach. Version in the URL path.
GET /api/v1/users
GET /api/v2/users
Implementation:
from fastapi import FastAPI, APIRouter
from enum import Enum
app = FastAPI()
v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")
@v1_router.get("/users")
def get_users_v1():
return [{"name": "John", "email": "[email protected]"}]
@v2_router.get("/users")
def get_users_v2():
return [
{
"id": "user-1",
"full_name": "John Doe",
"email": "[email protected]",
"created_at": "2024-01-15T10:00:00Z"
}
]
app.include_router(v1_router)
app.include_router(v2_router)
Pros:
- Easy to understand
- Visible in every request
- Easy to route to different handlers
- Cache-friendly
Cons:
- URL changes on every version
- Duplication if handlers are similar
2. Header Versioning
Version in HTTP header.
GET /api/users
Accept-Version: v1
GET /api/users
Accept-Version: v2
Implementation:
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/users")
def get_users(accept_version: str = Header(default="v1")):
if accept_version == "v2":
return get_users_v2()
return get_users_v1()
Or using Accept header with media types:
GET /api/users
Accept: application/vnd.example.v2+json
from fastapi import FastAPI, Header
@app.get("/users")
def get_users(
accept: str = Header(default="application/json")
):
if "v2" in accept:
return get_users_v2()
return get_users_v1()
Pros:
- Clean URLs
- Multiple versions same URL
Cons:
- Less visible
- More complex routing
- Harder to test
3. Query Parameter Versioning
Version as query parameter.
GET /api/users?version=1
GET /api/users?version=2
Implementation:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/users")
def get_users(version: int = Query(default=1, ge=1, le=2)):
if version == 2:
return get_users_v2()
return get_users_v1()
Pros:
- Single endpoint
- Easy to test
Cons:
- Query params often cached with wrong version
- Cumbersome for simple endpoints
Strategy Comparison
| Strategy | URL Clear | Cache | Client Easy | Complex |
|---|---|---|---|---|
| URL Path | Yes | Yes | Yes | Low |
| Header | No | Maybe | No | Medium |
| Query Param | No | No | Yes | Low |
Recommendation: Use URL path versioning for most APIs. It’s the clearest and most straightforward.
Semantic Versioning for APIs
Follow semantic versioning principles:
vMAJOR.MINOR.PATCH
MAJOR - Breaking changes
MINOR - New features (backward compatible)
PATCH - Bug fixes
# API version as enum
from enum import Enum
class APIVersion(Enum):
V1_0 = "1.0.0" # Initial release
V1_1 = "1.1.0" # Added new field
V1_2 = "1.2.0" # Added endpoint
V2_0 = "2.0.0" # Breaking changes
Breaking vs Non-Breaking Changes
Non-Breaking Changes (MINOR)
- Adding new optional fields
- Adding new endpoints
- Adding new response fields
- Changing field order
- Adding new enum values
# v1 response
{"name": "John", "email": "[email protected]"}
# v1.1 response - adding new field (non-breaking)
{"name": "John", "email": "[email protected]", "avatar_url": null}
# v1.2 response - adding endpoint (non-breaking)
# New endpoint: GET /users/{id}/orders
Breaking Changes (MAJOR)
- Removing fields
- Renaming fields
- Changing field types
- Changing response format
- Removing endpoints
- Changing validation rules
- Changing authentication requirements
# v1 response
{"name": "John", "email": "[email protected]"}
# v2 - breaking: renamed fields
{"full_name": "John Doe", "contact_email": "[email protected]"}
# v2 - breaking: changed type
{"name": "John", "email": "[email protected]", "age": "25"} # was integer
Deprecation Strategy
Announce Early
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
from fastapi import FastAPI, Response
from datetime import datetime, timedelta
@app.get("/users")
def get_users(response: Response):
# Add deprecation headers 6 months before removing
if is_deprecated("v1"):
response.headers["Deprecation"] = "true"
sunset_date = datetime.utcnow() + timedelta(days=180)
response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
response.headers["Link"] = '</v2/users>; rel="successor-version"'
return get_users_v1()
Grace Period Timeline
Month 1: Announce deprecation, no timeline
Month 3: Set sunset date 6 months out
Month 6: Add deprecation warnings in responses
Month 9: Start returning 410 Gone for deprecated endpoints
Month 12: Remove deprecated version
Deprecation Response Format
{
"error": "deprecated",
"message": "This endpoint is deprecated",
"sunset_date": "2024-06-01T00:00:00Z",
"documentation": "https://docs.example.com/migration-guide",
"successor": "/v2/users"
}
Implementing Version Negotiation
Version Detection
from fastapi import FastAPI, Request
from typing import Optional
def get_api_version(request: Request) -> str:
# Check URL path first
path = request.url.path
if "/v2/" in path:
return "v2"
if "/v1/" in path:
return "v1"
# Check header
version = request.headers.get("Accept-Version")
if version:
return version
# Check query param
version = request.query_params.get("version")
if version:
return f"v{version}"
# Default to latest stable
return "v2"
Version-Specific Response Schemas
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserV1(BaseModel):
name: str
email: str
class UserV2(BaseModel):
id: str
full_name: str
email: str
avatar_url: Optional[str] = None
created_at: datetime
@app.get("/users/{user_id}")
def get_user(user_id: str, version: str = "v2"):
user = user_service.get(user_id)
if version == "v1":
return UserV1(name=user.name, email=user.email)
return UserV2(
id=user.id,
full_name=user.full_name,
email=user.email,
avatar_url=user.avatar_url,
created_at=user.created_at
)
Migration Strategies
Parallel Running
Run old and new versions simultaneously:
# Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-v1
spec:
replicas: 3
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-v2
spec:
replicas: 5 # More capacity for new version
# Traffic splitting
@app.middleware("http")
async def route_to_version(request: Request, call_next):
if is_deprecated(request):
# Route to v1 with smaller percentage
if random.random() > 0.1:
request.url.path = request.url.path.replace("/v1/", "/v2/")
return await call_next(request)
Feature Flags for Users
# Let users opt-in to new version
class User:
def __init__(self):
self.api_version_preference = "v1" # or "v2-beta"
@app.get("/users/me")
def get_current_user(user: User = Depends(get_current_user)):
version = user.api_version_preference
return get_users(version=version)
Feature Flags in Code
from featureflags import flag
@app.get("/users")
@flag("new-user-endpoint")
def get_users():
if flag.enabled("new-user-endpoint"):
return get_users_v2()
return get_users_v1()
Response Format Versioning
Embed Version in Response
{
"version": "2.0",
"data": {...}
}
Using JSON:API Style
{
"jsonapi": {
"version": "1.0"
},
"data": {...}
}
Best Practices
Do
- Version from day one
- Document changes clearly
- Maintain backward compatibility within major versions
- Provide clear migration guides
- Use deprecation headers
- Support multiple versions during transitions
Don’t
- Make breaking changes without versioning
- Remove versions without notice
- Force clients to upgrade
- Version for every small change
- Break the principle of least surprise
Migration Checklist
When releasing a new major version:
- Document all breaking changes
- Create migration guide
- Update API documentation
- Set deprecation headers on old version
- Notify API consumers
- Run parallel systems during transition
- Monitor both versions
- Set sunset date
- Remove old version after grace period
Conclusion
API versioning is not optional - it’s how you evolve without breaking your users:
- Use URL path versioning for clarity
- Follow semantic versioning
- Give users 6-12 months to migrate
- Always announce deprecations early
- Maintain backward compatibility within versions
- Document everything
External Resources
Related Articles
- API Design Guidelines - REST API best practices
- Software Architecture Patterns - API architecture
Comments