API Versioning: Strategies for Breaking Change Management

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

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)

Header Versioning#

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

Content Negotiation with Accept 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.

Contents