API Versioning Strategies
Design APIs that evolve gracefully without breaking clients.
Strategy Comparison
Strategy Example Pros Cons
URL Path /api/v1/users
Simple, visible, cacheable URL pollution
Header X-API-Version: 1
Clean URLs Hidden, harder to test
Query Param ?version=1
Easy testing Messy, cache issues
Content-Type Accept: application/vnd.api.v1+json
RESTful Complex
URL Path Versioning (Recommended)
FastAPI Structure
backend/app/ ├── api/ │ ├── v1/ │ │ ├── init.py │ │ ├── routes/ │ │ │ ├── users.py │ │ │ └── analyses.py │ │ └── router.py │ ├── v2/ │ │ ├── init.py │ │ ├── routes/ │ │ │ ├── users.py # Updated schemas │ │ │ └── analyses.py │ │ └── router.py │ └── router.py # Combines all versions
Router Setup
backend/app/api/router.py
from fastapi import APIRouter from app.api.v1.router import router as v1_router from app.api.v2.router import router as v2_router
api_router = APIRouter() api_router.include_router(v1_router, prefix="/v1") api_router.include_router(v2_router, prefix="/v2")
main.py
app.include_router(api_router, prefix="/api")
Version-Specific Schemas
v1/schemas/user.py
class UserResponseV1(BaseModel): id: str name: str # Single name field
v2/schemas/user.py
class UserResponseV2(BaseModel): id: str first_name: str # Split into first/last last_name: str full_name: str # Computed for convenience
Shared Business Logic
services/user_service.py (version-agnostic)
class UserService: async def get_user(self, user_id: str) -> User: return await self.repo.get_by_id(user_id)
v1/routes/users.py
@router.get("/{user_id}", response_model=UserResponseV1) async def get_user_v1(user_id: str, service: UserService = Depends()): user = await service.get_user(user_id) return UserResponseV1(id=user.id, name=user.full_name)
v2/routes/users.py
@router.get("/{user_id}", response_model=UserResponseV2) async def get_user_v2(user_id: str, service: UserService = Depends()): user = await service.get_user(user_id) return UserResponseV2( id=user.id, first_name=user.first_name, last_name=user.last_name, full_name=f"{user.first_name} {user.last_name}", )
Header-Based Versioning
from fastapi import Header, HTTPException
async def get_api_version( x_api_version: str = Header(default="1", alias="X-API-Version") ) -> int: try: version = int(x_api_version) if version not in [1, 2]: raise ValueError() return version except ValueError: raise HTTPException(400, "Invalid API version")
@router.get("/users/{user_id}") async def get_user( user_id: str, version: int = Depends(get_api_version), service: UserService = Depends(), ): user = await service.get_user(user_id)
if version == 1:
return UserResponseV1(id=user.id, name=user.full_name)
else:
return UserResponseV2(
id=user.id,
first_name=user.first_name,
last_name=user.last_name,
)
Content Negotiation
from fastapi import Request
MEDIA_TYPES = { "application/vnd.orchestkit.v1+json": 1, "application/vnd.orchestkit.v2+json": 2, "application/json": 2, # Default to latest }
async def get_version_from_accept(request: Request) -> int: accept = request.headers.get("Accept", "application/json") return MEDIA_TYPES.get(accept, 2)
@router.get("/users/{user_id}") async def get_user( user_id: str, version: int = Depends(get_version_from_accept), ): ...
Deprecation Headers
from fastapi import Response from datetime import date
def add_deprecation_headers( response: Response, deprecated_date: date, sunset_date: date, link: str, ): response.headers["Deprecation"] = deprecated_date.isoformat() response.headers["Sunset"] = sunset_date.isoformat() response.headers["Link"] = f'<{link}>; rel="successor-version"'
Usage in v1 endpoints
@router.get("/users/{user_id}") async def get_user_v1(user_id: str, response: Response): add_deprecation_headers( response, deprecated_date=date(2025, 1, 1), sunset_date=date(2025, 7, 1), link="https://api.example.com/v2/users", ) return await service.get_user(user_id)
Version Lifecycle
┌─────────────────────────────────────────────────────────────────┐ │ VERSION LIFECYCLE │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────────┐ │ │ │ ALPHA │ → │ BETA │ → │ STABLE │ → │ DEPRECATED │ │ │ │ (dev) │ │ (test) │ │ (prod) │ │ (sunset) │ │ │ └─────────┘ └─────────┘ └──────────┘ └─────────────┘ │ │ │ │ v3-alpha v3-beta v2 (current) v1 (6 months) │ │ │ ├─────────────────────────────────────────────────────────────────┤ │ POLICY: │ │ • Deprecation notice: 3 months before sunset │ │ • Sunset period: 6 months after deprecation │ │ • Support: Latest stable + 1 previous version │ └─────────────────────────────────────────────────────────────────┘
Breaking vs Non-Breaking Changes
Non-Breaking (No Version Bump)
Adding optional fields
class UserResponse(BaseModel): id: str name: str avatar_url: str | None = None # New optional field
Adding new endpoints
@router.get("/users/{user_id}/preferences") # New endpoint
Adding optional query params
@router.get("/users") async def list_users( limit: int = 100, cursor: str | None = None, # New pagination ):
Breaking (Requires Version Bump)
Removing fields
Renaming fields
Changing field types
Changing URL structure
Changing authentication
Removing endpoints
Changing error formats
OpenAPI Per Version
from fastapi import FastAPI from fastapi.openapi.utils import get_openapi
def custom_openapi_v1(): return get_openapi( title="OrchestKit API", version="1.0.0", routes=v1_router.routes, )
def custom_openapi_v2(): return get_openapi( title="OrchestKit API", version="2.0.0", routes=v2_router.routes, )
app.mount("/docs/v1", create_docs_app(custom_openapi_v1)) app.mount("/docs/v2", create_docs_app(custom_openapi_v2))
Anti-Patterns (FORBIDDEN)
NEVER version internal implementation
class UserServiceV1: # Services should be version-agnostic ...
NEVER break contracts without versioning
class UserResponse(BaseModel):
# Changed from name to full_name without version bump!
full_name: str
NEVER sunset without notice
Just removing v1 routes one day
NEVER support too many versions (max 2-3)
/api/v1/... # Ancient /api/v2/... /api/v3/... /api/v4/... # Too many!
Key Decisions
Decision Recommendation
Strategy URL path (/api/v1/ )
Support window Current + 1 previous
Deprecation notice 3 months minimum
Sunset period 6 months after deprecation
Breaking changes New major version
Additive changes Same version (backward compatible)
Related Skills
-
api-design-framework
-
REST API patterns
-
error-handling-rfc9457
-
Consistent errors across versions
-
observability-monitoring
-
Version usage metrics
Capability Details
url-versioning
Keywords: url version, path version, /v1/, /v2/ Solves:
-
How to version REST APIs?
-
URL-based API versioning
header-versioning
Keywords: header version, X-API-Version, custom header Solves:
-
Clean URL versioning
-
Header-based API version
deprecation
Keywords: deprecation, sunset, version lifecycle, backward compatible Solves:
-
How to deprecate API versions?
-
Version sunset policy
breaking-changes
Keywords: breaking change, non-breaking, backward compatible Solves:
-
What requires a version bump?
-
Breaking vs non-breaking changes