Introduction#
API versioning is how you introduce breaking changes without breaking existing clients. The strategy you choose — URL path versioning, header versioning, or query parameter versioning — affects client ergonomics, caching, routing, and deprecation workflows. This post covers the tradeoffs and implementation patterns for each.
What Constitutes a Breaking Change#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| Breaking changes (require a new version):
- Removing or renaming a field in a response
- Changing a field's data type (e.g., string → integer)
- Removing an endpoint
- Changing required request fields
- Changing authentication scheme
- Changing error response format
Non-breaking changes (safe to add in-place):
- Adding new optional fields to a response
- Adding new optional request parameters
- Adding new endpoints
- Adding new enum values (with care)
- Adding new error codes
- Performance improvements
|
URL Path Versioning#
The most common approach. Version is explicit in the URL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| from fastapi import FastAPI, APIRouter
app = FastAPI()
# v1 router
v1 = APIRouter(prefix="/v1")
@v1.get("/users/{user_id}")
async def get_user_v1(user_id: int):
return {
"id": user_id,
"name": "Alice Smith", # v1: single name field
"email": "alice@example.com",
}
@v1.post("/users")
async def create_user_v1(name: str, email: str):
return {"id": 1, "name": name, "email": email}
# v2 router: split name into first_name/last_name
v2 = APIRouter(prefix="/v2")
@v2.get("/users/{user_id}")
async def get_user_v2(user_id: int):
return {
"id": user_id,
"first_name": "Alice", # v2: split name fields
"last_name": "Smith",
"email": "alice@example.com",
"created_at": "2025-01-15T10:00:00Z", # new field
}
@v2.post("/users")
async def create_user_v2(first_name: str, last_name: str, email: str):
return {"id": 1, "first_name": first_name, "last_name": last_name, "email": email}
app.include_router(v1)
app.include_router(v2)
|
Version specified in Accept or custom API-Version header.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
api_version: str = Header(default="2025-01-01", alias="API-Version"),
):
if api_version == "2024-01-01":
return {"id": user_id, "name": "Alice Smith"}
elif api_version == "2025-01-01":
return {"id": user_id, "first_name": "Alice", "last_name": "Smith"}
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {api_version}. Use '2024-01-01' or '2025-01-01'."
)
# Stripe uses this approach: "Stripe-Version: 2024-06-20"
# Advantages: clean URLs, single endpoint to maintain
# Disadvantages: harder to test in browser, not cacheable without Vary header
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
accept: str = Header(default="application/json"),
):
user_v1 = {"id": user_id, "name": "Alice Smith"}
user_v2 = {"id": user_id, "first_name": "Alice", "last_name": "Smith"}
if "application/vnd.myapi.v2+json" in accept:
return JSONResponse(
content=user_v2,
headers={"Content-Type": "application/vnd.myapi.v2+json"},
)
# Default to v1
return JSONResponse(
content=user_v1,
headers={"Content-Type": "application/vnd.myapi.v1+json"},
)
# RFC-compliant but complex — rarely used outside large public APIs
|
Version Router Factory Pattern#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| from fastapi import FastAPI, APIRouter
from typing import Callable
def versioned_router(versions: list[str]) -> dict[str, APIRouter]:
return {v: APIRouter(prefix=f"/{v}") for v in versions}
# Define handlers independently of version
class UserHandlers:
@staticmethod
async def get_user_v1(user_id: int):
return {"id": user_id, "name": "Alice"}
@staticmethod
async def get_user_v2(user_id: int):
return {"id": user_id, "first_name": "Alice", "last_name": "Smith"}
app = FastAPI()
routers = versioned_router(["v1", "v2"])
routers["v1"].get("/users/{user_id}")(UserHandlers.get_user_v1)
routers["v2"].get("/users/{user_id}")(UserHandlers.get_user_v2)
for router in routers.values():
app.include_router(router)
|
Deprecation Workflow#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| import warnings
from datetime import date
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
app = FastAPI()
DEPRECATION_SCHEDULE = {
"v1": {
"deprecated_on": "2025-01-01",
"sunset_on": "2026-01-01",
"message": "v1 is deprecated. Migrate to v2 by 2026-01-01.",
"migration_guide": "https://docs.example.com/v1-to-v2",
}
}
@app.middleware("http")
async def deprecation_headers(request: Request, call_next):
response = await call_next(request)
# Add deprecation headers if the request is for a deprecated version
for version, info in DEPRECATION_SCHEDULE.items():
if request.url.path.startswith(f"/{version}/"):
response.headers["Deprecation"] = info["deprecated_on"]
response.headers["Sunset"] = info["sunset_on"]
response.headers["Link"] = (
f'<{info["migration_guide"]}>; rel="deprecation"'
)
response.headers["Warning"] = f'299 - "{info["message"]}"'
return response
# Log usage of deprecated versions for client outreach
@app.middleware("http")
async def log_deprecated_usage(request: Request, call_next):
response = await call_next(request)
if "Deprecation" in response.headers:
logger.info(
"deprecated_api_used path=%s user_agent=%s",
request.url.path,
request.headers.get("User-Agent"),
)
return response
|
API Version Compatibility Layer#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| from pydantic import BaseModel
from typing import Optional
# Store data in the latest format, transform for older versions
class UserV2(BaseModel):
id: int
first_name: str
last_name: str
email: str
created_at: str
def to_v1(user: UserV2) -> dict:
"""Downgrade v2 response to v1 format."""
return {
"id": user.id,
"name": f"{user.first_name} {user.last_name}",
"email": user.email,
# created_at not present in v1
}
# Single source of truth for data, multiple presentation layers
@v1.get("/users/{user_id}")
async def get_user_v1_compat(user_id: int, db: Session = Depends(get_db)):
user = get_user_from_db(user_id, db) # returns UserV2
return to_v1(user)
@v2.get("/users/{user_id}")
async def get_user_v2_endpoint(user_id: int, db: Session = Depends(get_db)):
user = get_user_from_db(user_id, db)
return user
|
Versioning Checklist#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| Versioning strategy selection:
- [ ] URL path (/v1, /v2): most common, explicit, easy to test
- [ ] Date-based header: Stripe-style, clean evolution
- [ ] Content negotiation: RFC-correct, complex to implement
Deprecation process:
- [ ] Announce deprecation at least 6 months before sunset
- [ ] Add Deprecation and Sunset response headers
- [ ] Add Warning header with migration instructions
- [ ] Log deprecated endpoint usage for client tracking
- [ ] Reach out to clients using deprecated version
Backward compatibility:
- [ ] New fields are optional
- [ ] Existing fields never removed from current version
- [ ] Old versions remain functional until sunset date
- [ ] Migration guide published with each new version
|
Conclusion#
URL path versioning is the most practical choice for most APIs — it is explicit, testable, and intuitive. Header-based versioning (like Stripe’s date-based versioning) produces cleaner URLs but requires careful Vary header management for caching. Regardless of strategy, the key practices are: never make breaking changes in-place, add Deprecation and Sunset headers on deprecated routes, and give clients at least 6 months to migrate before removing old versions.