| name | api-versioning |
| description | API versioning strategies including URL path, header, and content negotiation. Use when designing version evolution, deprecation policies, or multi-version support. |
| context | fork |
| agent | backend-system-architect |
| version | 1.0.0 |
| tags | api, versioning, rest, fastapi, backward-compatibility, 2026 |
API Versioning Strategies
Design APIs that evolve gracefully without breaking clients.
When to Use
- Launching a public API
- Planning breaking changes
- Supporting multiple client versions
- Implementing deprecation policies
- Designing API evolution strategy
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.skillforge.v1+json": 1,
"application/vnd.skillforge.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="SkillForge API",
version="1.0.0",
routes=v1_router.routes,
)
def custom_openapi_v2():
return get_openapi(
title="SkillForge 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 patternserror-handling-rfc9457- Consistent errors across versionsobservability-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