Skip to content

API Versioning

Evolve your API without breaking existing clients.

Versioning Strategies

Strategy Example Pros Cons
URL path /v1/users Clear, easy routing URL changes
Header API-Version: 1 Clean URLs Hidden version
Query param ?version=1 Easy to test Pollutes params
Content type Accept: application/vnd.api.v1+json RESTful Complex

Basic Setup

from fastapi import FastAPI, APIRouter

app = FastAPI()

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

@v1_router.get("/users")
async def list_users_v1():
    return {"version": 1, "users": [...]}

# Version 2 router
v2_router = APIRouter(prefix="/v2")

@v2_router.get("/users")
async def list_users_v2():
    # New response format in v2
    return {"data": [...], "meta": {"version": 2}}

# Include both versions
app.include_router(v1_router)
app.include_router(v2_router)

Shared Logic

from fastapi import APIRouter, Depends
from typing import Optional

# Shared service layer
class UserService:
    async def list_users(self, include_profile: bool = False):
        users = await self.repo.get_all()
        if include_profile:
            return [await self.enrich_with_profile(u) for u in users]
        return users

# V1: Simple response
v1_router = APIRouter(prefix="/v1")

@v1_router.get("/users")
async def list_users_v1(service: UserService = Depends()):
    users = await service.list_users()
    # V1 format: flat list
    return users

# V2: Enriched response
v2_router = APIRouter(prefix="/v2")

@v2_router.get("/users")
async def list_users_v2(service: UserService = Depends()):
    users = await service.list_users(include_profile=True)
    # V2 format: envelope with metadata
    return {
        "data": users,
        "meta": {"total": len(users)},
    }

Version-Specific Models

from pydantic import BaseModel
from datetime import datetime

# V1 models
class UserV1(BaseModel):
    id: int
    name: str
    email: str

# V2 models (extended)
class UserV2(BaseModel):
    id: int
    full_name: str  # Renamed from 'name'
    email: str
    created_at: datetime
    profile: Optional[dict] = None

# V1 endpoint
@v1_router.get("/users/{user_id}", response_model=UserV1)
async def get_user_v1(user_id: int):
    user = await get_user(user_id)
    return UserV1(
        id=user.id,
        name=user.full_name,  # Map to old field
        email=user.email,
    )

# V2 endpoint
@v2_router.get("/users/{user_id}", response_model=UserV2)
async def get_user_v2(user_id: int):
    user = await get_user(user_id)
    return UserV2(
        id=user.id,
        full_name=user.full_name,
        email=user.email,
        created_at=user.created_at,
        profile=user.profile,
    )

Header Versioning

from fastapi import FastAPI, Header, HTTPException

app = FastAPI()

@app.get("/users")
async def list_users(
    api_version: str = Header("1", alias="API-Version"),
):
    if api_version == "1":
        return await list_users_v1()
    elif api_version == "2":
        return await list_users_v2()
    else:
        raise HTTPException(400, f"Unsupported API version: {api_version}")

Version Middleware

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

class VersionMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # Get version from header
        version = request.headers.get("API-Version", "1")

        # Store in request state
        request.state.api_version = version

        response = await call_next(request)

        # Echo version in response
        response.headers["API-Version"] = version

        return response

app.add_middleware(VersionMiddleware)

# Use in endpoints
@app.get("/users")
async def list_users(request: Request):
    version = request.state.api_version
    # ... handle based on version

Version Lifecycle

Stages

┌─────────────────────────────────────────────────────────────┐
│                     Version Lifecycle                        │
├─────────┬─────────┬─────────┬─────────┬─────────┬──────────┤
│ Preview │  Beta   │ Stable  │ Sunset  │ Deprecated │ Retired │
├─────────┼─────────┼─────────┼─────────┼─────────┼──────────┤
│ Testing │ Limited │ Full    │ Warning │ Errors  │ Removed  │
│         │ use     │ support │ headers │ logged  │          │
└─────────┴─────────┴─────────┴─────────┴─────────┴──────────┘

Implementation

from enum import Enum
from datetime import date

class VersionStatus(str, Enum):
    PREVIEW = "preview"
    BETA = "beta"
    STABLE = "stable"
    SUNSET = "sunset"
    DEPRECATED = "deprecated"

VERSION_INFO = {
    "v1": {
        "status": VersionStatus.SUNSET,
        "sunset_date": date(2024, 6, 1),
        "successor": "v2",
    },
    "v2": {
        "status": VersionStatus.STABLE,
        "released": date(2024, 1, 1),
    },
    "v3": {
        "status": VersionStatus.BETA,
        "note": "Breaking changes may occur",
    },
}

@app.middleware("http")
async def version_headers(request: Request, call_next):
    response = await call_next(request)

    # Extract version from path
    path = request.url.path
    for version, info in VERSION_INFO.items():
        if f"/{version}/" in path:
            status = info["status"]

            if status == VersionStatus.SUNSET:
                response.headers["Sunset"] = info["sunset_date"].isoformat()
                response.headers["Deprecation"] = "true"
                response.headers["Link"] = f'</api/{info["successor"]}>; rel="successor-version"'

            elif status == VersionStatus.DEPRECATED:
                response.headers["Deprecation"] = "true"

            elif status == VersionStatus.BETA:
                response.headers["X-API-Warn"] = "Beta version - breaking changes possible"

            break

    return response

Migration Path

Deprecation Notice

import warnings
from fastapi import Depends
import structlog

log = structlog.get_logger()

def deprecated_endpoint(
    successor: str,
    sunset_date: str,
):
    def wrapper(func):
        async def inner(*args, **kwargs):
            log.warning(
                "deprecated_endpoint_called",
                endpoint=func.__name__,
                successor=successor,
                sunset_date=sunset_date,
            )
            return await func(*args, **kwargs)
        return inner
    return wrapper

@v1_router.get("/users")
@deprecated_endpoint(successor="/v2/users", sunset_date="2024-06-01")
async def list_users_v1():
    """
    **DEPRECATED**: Use `/v2/users` instead.

    This endpoint will be removed on 2024-06-01.
    """
    pass

Client Migration Guide

# Migration Guide: v1 to v2

## Breaking Changes

### User Endpoint

| v1 | v2 | Notes |
|----|----|----|
| `name` | `full_name` | Field renamed |
| - | `created_at` | New field added |
| - | `profile` | New nested object |

### Response Format

**v1:**
```json
[
  {"id": 1, "name": "John"}
]

v2:

{
  "data": [
    {"id": 1, "full_name": "John", "created_at": "..."}
  ],
  "meta": {"total": 1}
}

Migration Steps

  1. Update API base URL from /v1 to /v2
  2. Update response parsing to handle envelope format
  3. Update field names: namefull_name
  4. Handle new optional fields
    ## Versioning Best Practices
    
    ### When to Create New Version
    
    | Change | New Version? |
    |--------|--------------|
    | Add new endpoint | No |
    | Add optional field | No |
    | Add required field | Yes |
    | Remove field | Yes |
    | Rename field | Yes |
    | Change field type | Yes |
    | Change response structure | Yes |
    | Change authentication | Yes |
    
    ### Version Maintenance
    
    ```python
    # Keep at most 2-3 active versions
    SUPPORTED_VERSIONS = ["v2", "v3"]  # v1 retired
    DEFAULT_VERSION = "v2"
    
    @app.middleware("http")
    async def check_version(request: Request, call_next):
        path = request.url.path
    
        # Check if using unsupported version
        if "/v1/" in path:
            return JSONResponse(
                status_code=410,  # Gone
                content={
                    "error": "version_retired",
                    "message": "API v1 is no longer available",
                    "migrate_to": "/v2",
                    "documentation": "https://docs.example.com/migration",
                },
            )
    
        return await call_next(request)
    

Semantic Versioning for APIs

MAJOR.MINOR.PATCH

MAJOR: Breaking changes (v1 → v2)
MINOR: New features, backwards compatible
PATCH: Bug fixes, backwards compatible
from fastapi import FastAPI

app = FastAPI(
    title="My API",
    version="2.3.1",  # Full semantic version
)

# URL still uses major version only
# /v2/users

# Full version in response header
@app.middleware("http")
async def version_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-API-Version"] = "2.3.1"
    return response

Testing Multiple Versions

import pytest
from httpx import AsyncClient

@pytest.fixture
def v1_client(app):
    return AsyncClient(app=app, base_url="http://test/v1")

@pytest.fixture
def v2_client(app):
    return AsyncClient(app=app, base_url="http://test/v2")

class TestUserEndpoint:
    async def test_v1_response_format(self, v1_client):
        response = await v1_client.get("/users")
        data = response.json()

        # V1 returns flat list
        assert isinstance(data, list)
        assert "name" in data[0]

    async def test_v2_response_format(self, v2_client):
        response = await v2_client.get("/users")
        data = response.json()

        # V2 returns envelope
        assert "data" in data
        assert "meta" in data
        assert "full_name" in data["data"][0]

Best Practices Summary

Practice Implementation
Use URL versioning Clear, cacheable, easy to route
Version major changes only Minor changes are backwards compatible
Announce deprecation early 6+ months notice
Maintain 2-3 versions max Reduces maintenance burden
Provide migration guides Clear documentation
Add sunset headers RFC 8594 compliance
Test all versions Ensure compatibility

See Also