APIs evolve. Fields get renamed, endpoints change, entire resources get restructured. The question isn’t whether to version your API—it’s how to do it without breaking every client that depends on you.

Versioning Strategies

1. URL Path Versioning

The most visible approach—version is part of the URL:

GGEETT//aappii//v12//uusseerrss//112233
 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
from fastapi import FastAPI, APIRouter

app = FastAPI()

# Version 1
v1_router = APIRouter(prefix="/api/v1")

@v1_router.get("/users/{user_id}")
async def get_user_v1(user_id: int):
    return {
        "id": user_id,
        "name": "John Doe",
        "email": "john@example.com"
    }

# Version 2 - restructured response
v2_router = APIRouter(prefix="/api/v2")

@v2_router.get("/users/{user_id}")
async def get_user_v2(user_id: int):
    return {
        "id": user_id,
        "profile": {
            "name": "John Doe",
            "email": "john@example.com"
        },
        "metadata": {
            "created_at": "2026-01-01T00:00:00Z",
            "updated_at": "2026-02-11T00:00:00Z"
        }
    }

app.include_router(v1_router)
app.include_router(v2_router)

Pros: Explicit, easy to understand, easy to route Cons: URLs change between versions, harder to maintain multiple versions

2. Header Versioning

Version specified in request headers:

GAEcTce/patp:i/aupspelrisc/a1t2i3on/vnd.myapi.v2+json
 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
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def get_api_version(request: Request) -> int:
    """Extract version from Accept header."""
    accept = request.headers.get("Accept", "")
    
    # Parse: application/vnd.myapi.v2+json
    if "vnd.myapi.v" in accept:
        try:
            version_str = accept.split("vnd.myapi.v")[1].split("+")[0]
            return int(version_str)
        except (IndexError, ValueError):
            pass
    
    # Default to latest stable version
    return 1


@app.get("/api/users/{user_id}")
async def get_user(user_id: int, request: Request):
    version = get_api_version(request)
    
    if version == 1:
        return {"id": user_id, "name": "John Doe"}
    elif version == 2:
        return {
            "id": user_id,
            "profile": {"name": "John Doe"},
            "metadata": {"version": "2"}
        }
    else:
        raise HTTPException(status_code=400, detail=f"Unknown API version: {version}")

Pros: Clean URLs, version is metadata not resource identifier Cons: Less discoverable, harder to test in browser

3. Query Parameter Versioning

Version as a query parameter:

GET/api/users/123?version=2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from fastapi import FastAPI, Query

@app.get("/api/users/{user_id}")
async def get_user(
    user_id: int,
    version: int = Query(default=1, ge=1, le=2)
):
    if version == 1:
        return {"id": user_id, "name": "John Doe"}
    else:
        return {"id": user_id, "profile": {"name": "John Doe"}}

Pros: Simple, easy to test Cons: Mixes versioning with query logic, can be accidentally omitted

Managing Multiple Versions

Shared Logic with Version-Specific Transforms

 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
44
45
46
47
48
49
50
51
52
53
54
55
from typing import Any
from pydantic import BaseModel

# Internal model (current version)
class UserInternal(BaseModel):
    id: int
    name: str
    email: str
    created_at: str
    is_active: bool

# Version-specific response models
class UserResponseV1(BaseModel):
    id: int
    name: str
    email: str

class UserResponseV2(BaseModel):
    id: int
    profile: dict
    metadata: dict
    status: str

# Transform functions
def to_v1(user: UserInternal) -> dict:
    return UserResponseV1(
        id=user.id,
        name=user.name,
        email=user.email
    ).dict()

def to_v2(user: UserInternal) -> dict:
    return UserResponseV2(
        id=user.id,
        profile={"name": user.name, "email": user.email},
        metadata={"created_at": user.created_at},
        status="active" if user.is_active else "inactive"
    ).dict()

TRANSFORMERS = {
    1: to_v1,
    2: to_v2,
}

@app.get("/api/v{version}/users/{user_id}")
async def get_user(version: int, user_id: int):
    # Fetch from database (version-agnostic)
    user = await fetch_user(user_id)
    
    # Transform to requested version
    transformer = TRANSFORMERS.get(version)
    if not transformer:
        raise HTTPException(status_code=400, detail="Invalid version")
    
    return transformer(user)

Version Middleware

 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
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

class VersionMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # Extract version from various sources
        version = (
            self._from_path(request.url.path) or
            self._from_header(request.headers) or
            self._from_query(request.query_params) or
            1  # default
        )
        
        # Add to request state
        request.state.api_version = version
        
        response = await call_next(request)
        response.headers["X-API-Version"] = str(version)
        return response
    
    def _from_path(self, path: str) -> int | None:
        import re
        match = re.search(r'/v(\d+)/', path)
        return int(match.group(1)) if match else None
    
    def _from_header(self, headers) -> int | None:
        version_header = headers.get("X-API-Version")
        return int(version_header) if version_header else None
    
    def _from_query(self, params) -> int | None:
        version = params.get("version")
        return int(version) if version else None

app.add_middleware(VersionMiddleware)

Deprecation Strategy

Sunset Headers

Signal deprecation with standard headers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from datetime import datetime, timedelta
from fastapi import Response

DEPRECATION_SCHEDULE = {
    1: datetime(2026, 6, 1),  # v1 sunsets June 1, 2026
}

@app.get("/api/v1/users/{user_id}")
async def get_user_v1(user_id: int, response: Response):
    sunset_date = DEPRECATION_SCHEDULE.get(1)
    
    if sunset_date:
        response.headers["Deprecation"] = "true"
        response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
        response.headers["Link"] = '</api/v2/users>; rel="successor-version"'
    
    return {"id": user_id, "name": "John Doe"}

Deprecation Logging

Track who’s still using deprecated versions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import logging
from collections import defaultdict

deprecated_usage = defaultdict(int)

@app.middleware("http")
async def track_deprecated_usage(request: Request, call_next):
    response = await call_next(request)
    
    version = getattr(request.state, 'api_version', 1)
    if version in DEPRECATION_SCHEDULE:
        client_id = request.headers.get("X-Client-ID", "unknown")
        deprecated_usage[(version, client_id)] += 1
        
        logging.warning(
            "deprecated_api_usage",
            extra={
                "version": version,
                "client_id": client_id,
                "endpoint": request.url.path
            }
        )
    
    return response

Breaking vs Non-Breaking Changes

Non-Breaking (Safe)

  • Adding new optional fields
  • Adding new endpoints
  • Adding new optional query parameters
  • Relaxing validation (accepting more input)
1
2
3
4
5
6
# Safe: Adding optional field
# Before
{"id": 1, "name": "John"}

# After - existing clients still work
{"id": 1, "name": "John", "avatar_url": "https://..."}

Breaking (Requires New Version)

  • Removing fields
  • Renaming fields
  • Changing field types
  • Changing URL structure
  • Adding required parameters
  • Stricter validation
1
2
3
4
5
6
# Breaking: Renamed field
# v1
{"id": 1, "name": "John"}

# v2 - clients expecting "name" will break
{"id": 1, "display_name": "John"}

Documentation

Keep version differences clear:

 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
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

app = FastAPI()

def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    
    openapi_schema = get_openapi(
        title="My API",
        version="2.0.0",
        description="""
## API Versions

### v2 (Current)
- Restructured user response with `profile` and `metadata` objects
- Added pagination to list endpoints

### v1 (Deprecated - Sunset: June 1, 2026)
- Legacy flat response structure
- No pagination support

## Migration Guide
See [migration docs](/docs/migration-v1-to-v2) for upgrade instructions.
        """,
        routes=app.routes,
    )
    
    app.openapi_schema = openapi_schema
    return app.openapi_schema

app.openapi = custom_openapi

Best Practices

  1. Start with versioning — it’s harder to add later
  2. Use URL versioning for public APIs — most discoverable
  3. Support at least N-1 — give clients time to migrate
  4. Communicate deprecation early — minimum 6 months notice
  5. Track version usage — know who needs to migrate
  6. Make migration easy — provide guides and tools
  7. Version the whole API, not individual endpoints — consistency matters

API versioning is about respect for your clients. They built systems depending on your contract. Change thoughtfully, communicate clearly, and give them time to adapt.