API Versioning¶
Evolve your API without breaking existing clients.
Versioning Strategies¶
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users |
Clear, easy routing | URL changes |
| Header | API-Version: 1 |
Clean URLs | Hidden version |
| Query param | ?version=1 |
Easy to test | Pollutes params |
| Content type | Accept: application/vnd.api.v1+json |
RESTful | Complex |
URL Path Versioning (Recommended)¶
Basic Setup¶
from fastapi import FastAPI, APIRouter
app = FastAPI()
# Version 1 router
v1_router = APIRouter(prefix="/v1")
@v1_router.get("/users")
async def list_users_v1():
return {"version": 1, "users": [...]}
# Version 2 router
v2_router = APIRouter(prefix="/v2")
@v2_router.get("/users")
async def list_users_v2():
# New response format in v2
return {"data": [...], "meta": {"version": 2}}
# Include both versions
app.include_router(v1_router)
app.include_router(v2_router)
Shared Logic¶
from fastapi import APIRouter, Depends
from typing import Optional
# Shared service layer
class UserService:
async def list_users(self, include_profile: bool = False):
users = await self.repo.get_all()
if include_profile:
return [await self.enrich_with_profile(u) for u in users]
return users
# V1: Simple response
v1_router = APIRouter(prefix="/v1")
@v1_router.get("/users")
async def list_users_v1(service: UserService = Depends()):
users = await service.list_users()
# V1 format: flat list
return users
# V2: Enriched response
v2_router = APIRouter(prefix="/v2")
@v2_router.get("/users")
async def list_users_v2(service: UserService = Depends()):
users = await service.list_users(include_profile=True)
# V2 format: envelope with metadata
return {
"data": users,
"meta": {"total": len(users)},
}
Version-Specific Models¶
from pydantic import BaseModel
from datetime import datetime
# V1 models
class UserV1(BaseModel):
id: int
name: str
email: str
# V2 models (extended)
class UserV2(BaseModel):
id: int
full_name: str # Renamed from 'name'
email: str
created_at: datetime
profile: Optional[dict] = None
# V1 endpoint
@v1_router.get("/users/{user_id}", response_model=UserV1)
async def get_user_v1(user_id: int):
user = await get_user(user_id)
return UserV1(
id=user.id,
name=user.full_name, # Map to old field
email=user.email,
)
# V2 endpoint
@v2_router.get("/users/{user_id}", response_model=UserV2)
async def get_user_v2(user_id: int):
user = await get_user(user_id)
return UserV2(
id=user.id,
full_name=user.full_name,
email=user.email,
created_at=user.created_at,
profile=user.profile,
)
Header Versioning¶
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.get("/users")
async def list_users(
api_version: str = Header("1", alias="API-Version"),
):
if api_version == "1":
return await list_users_v1()
elif api_version == "2":
return await list_users_v2()
else:
raise HTTPException(400, f"Unsupported API version: {api_version}")
Version Middleware¶
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class VersionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Get version from header
version = request.headers.get("API-Version", "1")
# Store in request state
request.state.api_version = version
response = await call_next(request)
# Echo version in response
response.headers["API-Version"] = version
return response
app.add_middleware(VersionMiddleware)
# Use in endpoints
@app.get("/users")
async def list_users(request: Request):
version = request.state.api_version
# ... handle based on version
Version Lifecycle¶
Stages¶
┌─────────────────────────────────────────────────────────────┐
│ Version Lifecycle │
├─────────┬─────────┬─────────┬─────────┬─────────┬──────────┤
│ Preview │ Beta │ Stable │ Sunset │ Deprecated │ Retired │
├─────────┼─────────┼─────────┼─────────┼─────────┼──────────┤
│ Testing │ Limited │ Full │ Warning │ Errors │ Removed │
│ │ use │ support │ headers │ logged │ │
└─────────┴─────────┴─────────┴─────────┴─────────┴──────────┘
Implementation¶
from enum import Enum
from datetime import date
class VersionStatus(str, Enum):
PREVIEW = "preview"
BETA = "beta"
STABLE = "stable"
SUNSET = "sunset"
DEPRECATED = "deprecated"
VERSION_INFO = {
"v1": {
"status": VersionStatus.SUNSET,
"sunset_date": date(2024, 6, 1),
"successor": "v2",
},
"v2": {
"status": VersionStatus.STABLE,
"released": date(2024, 1, 1),
},
"v3": {
"status": VersionStatus.BETA,
"note": "Breaking changes may occur",
},
}
@app.middleware("http")
async def version_headers(request: Request, call_next):
response = await call_next(request)
# Extract version from path
path = request.url.path
for version, info in VERSION_INFO.items():
if f"/{version}/" in path:
status = info["status"]
if status == VersionStatus.SUNSET:
response.headers["Sunset"] = info["sunset_date"].isoformat()
response.headers["Deprecation"] = "true"
response.headers["Link"] = f'</api/{info["successor"]}>; rel="successor-version"'
elif status == VersionStatus.DEPRECATED:
response.headers["Deprecation"] = "true"
elif status == VersionStatus.BETA:
response.headers["X-API-Warn"] = "Beta version - breaking changes possible"
break
return response
Migration Path¶
Deprecation Notice¶
import warnings
from fastapi import Depends
import structlog
log = structlog.get_logger()
def deprecated_endpoint(
successor: str,
sunset_date: str,
):
def wrapper(func):
async def inner(*args, **kwargs):
log.warning(
"deprecated_endpoint_called",
endpoint=func.__name__,
successor=successor,
sunset_date=sunset_date,
)
return await func(*args, **kwargs)
return inner
return wrapper
@v1_router.get("/users")
@deprecated_endpoint(successor="/v2/users", sunset_date="2024-06-01")
async def list_users_v1():
"""
**DEPRECATED**: Use `/v2/users` instead.
This endpoint will be removed on 2024-06-01.
"""
pass
Client Migration Guide¶
# Migration Guide: v1 to v2
## Breaking Changes
### User Endpoint
| v1 | v2 | Notes |
|----|----|----|
| `name` | `full_name` | Field renamed |
| - | `created_at` | New field added |
| - | `profile` | New nested object |
### Response Format
**v1:**
```json
[
{"id": 1, "name": "John"}
]
v2:
Migration Steps¶
- Update API base URL from
/v1to/v2 - Update response parsing to handle envelope format
- Update field names:
name→full_name - Handle new optional fields
## Versioning Best Practices ### When to Create New Version | Change | New Version? | |--------|--------------| | Add new endpoint | No | | Add optional field | No | | Add required field | Yes | | Remove field | Yes | | Rename field | Yes | | Change field type | Yes | | Change response structure | Yes | | Change authentication | Yes | ### Version Maintenance ```python # Keep at most 2-3 active versions SUPPORTED_VERSIONS = ["v2", "v3"] # v1 retired DEFAULT_VERSION = "v2" @app.middleware("http") async def check_version(request: Request, call_next): path = request.url.path # Check if using unsupported version if "/v1/" in path: return JSONResponse( status_code=410, # Gone content={ "error": "version_retired", "message": "API v1 is no longer available", "migrate_to": "/v2", "documentation": "https://docs.example.com/migration", }, ) return await call_next(request)
Semantic Versioning for APIs¶
MAJOR.MINOR.PATCH
MAJOR: Breaking changes (v1 → v2)
MINOR: New features, backwards compatible
PATCH: Bug fixes, backwards compatible
from fastapi import FastAPI
app = FastAPI(
title="My API",
version="2.3.1", # Full semantic version
)
# URL still uses major version only
# /v2/users
# Full version in response header
@app.middleware("http")
async def version_header(request: Request, call_next):
response = await call_next(request)
response.headers["X-API-Version"] = "2.3.1"
return response
Testing Multiple Versions¶
import pytest
from httpx import AsyncClient
@pytest.fixture
def v1_client(app):
return AsyncClient(app=app, base_url="http://test/v1")
@pytest.fixture
def v2_client(app):
return AsyncClient(app=app, base_url="http://test/v2")
class TestUserEndpoint:
async def test_v1_response_format(self, v1_client):
response = await v1_client.get("/users")
data = response.json()
# V1 returns flat list
assert isinstance(data, list)
assert "name" in data[0]
async def test_v2_response_format(self, v2_client):
response = await v2_client.get("/users")
data = response.json()
# V2 returns envelope
assert "data" in data
assert "meta" in data
assert "full_name" in data["data"][0]
Best Practices Summary¶
| Practice | Implementation |
|---|---|
| Use URL versioning | Clear, cacheable, easy to route |
| Version major changes only | Minor changes are backwards compatible |
| Announce deprecation early | 6+ months notice |
| Maintain 2-3 versions max | Reduces maintenance burden |
| Provide migration guides | Clear documentation |
| Add sunset headers | RFC 8594 compliance |
| Test all versions | Ensure compatibility |
See Also¶
- Backwards Compatibility -- Rules for non-breaking changes and deprecation policies
- Backend API Design -- Naming conventions, response envelopes, and HTTP method usage