Skip to content

API Versioning

Learn how to version your APIs properly using the boilerplate's built-in versioning structure and best practices for maintaining backward compatibility.

Quick Start

The boilerplate is already set up for versioning with a v1 structure:

src/app/api/
├── dependencies.py          # Shared across all versions
└── v1/                     # Version 1 of your API
    ├── __init__.py         # Router registration
    ├── users.py           # User endpoints
    ├── posts.py           # Post endpoints
    └── ...                # Other endpoints

Your endpoints are automatically available at /api/v1/...:

  • GET /api/v1/users/ - Get users
  • POST /api/v1/users/ - Create user
  • GET /api/v1/posts/ - Get posts

Current Structure

Version 1 (v1)

The current API version is in src/app/api/v1/:

# src/app/api/v1/__init__.py
from fastapi import APIRouter

from .users import router as users_router
from .posts import router as posts_router
from .login import router as login_router

# Main v1 router
api_router = APIRouter()

# Include all v1 endpoints
api_router.include_router(users_router)
api_router.include_router(posts_router)
api_router.include_router(login_router)

Main App Registration

In src/app/main.py, v1 is registered:

from fastapi import FastAPI
from app.api.v1 import api_router as api_v1_router

app = FastAPI()

# Register v1 API
app.include_router(api_v1_router, prefix="/api/v1")

Adding Version 2

When you need to make breaking changes, create a new version:

Step 1: Create v2 Directory

src/app/api/
├── dependencies.py
├── v1/                     # Keep v1 unchanged
│   ├── __init__.py
│   ├── users.py
│   └── ...
└── v2/                     # New version
    ├── __init__.py
    ├── users.py           # Updated user endpoints
    └── ...

Step 2: Create v2 Router

# src/app/api/v2/__init__.py
from fastapi import APIRouter

from .users import router as users_router
# Import other v2 routers

# Main v2 router
api_router = APIRouter()

# Include v2 endpoints
api_router.include_router(users_router)

Step 3: Register v2 in Main App

# src/app/main.py
from fastapi import FastAPI
from app.api.v1 import api_router as api_v1_router
from app.api.v2 import api_router as api_v2_router

app = FastAPI()

# Register both versions
app.include_router(api_v1_router, prefix="/api/v1")
app.include_router(api_v2_router, prefix="/api/v2")

Version 2 Example

Here's how you might evolve the user endpoints in v2:

v1 User Endpoint

# src/app/api/v1/users.py
from app.schemas.user import UserRead, UserCreate

@router.get("/", response_model=list[UserRead])
async def get_users():
    users = await crud_users.get_multi(db=db, schema_to_select=UserRead)
    return users["data"]

@router.post("/", response_model=UserRead)
async def create_user(user_data: UserCreate):
    return await crud_users.create(db=db, object=user_data)

v2 User Endpoint (with breaking changes)

# src/app/api/v2/users.py
from app.schemas.user import UserReadV2, UserCreateV2  # New schemas
from fastcrud.paginated import PaginatedListResponse

# Breaking change: Always return paginated response
@router.get("/", response_model=PaginatedListResponse[UserReadV2])
async def get_users(page: int = 1, items_per_page: int = 10):
    users = await crud_users.get_multi(
        db=db,
        offset=(page - 1) * items_per_page,
        limit=items_per_page,
        schema_to_select=UserReadV2
    )
    return paginated_response(users, page, items_per_page)

# Breaking change: Require authentication
@router.post("/", response_model=UserReadV2)
async def create_user(
    user_data: UserCreateV2,
    current_user: Annotated[dict, Depends(get_current_user)]  # Now required
):
    return await crud_users.create(db=db, object=user_data)

Schema Versioning

Create separate schemas for different versions:

Version 1 Schema

# src/app/schemas/user.py (existing)
class UserRead(BaseModel):
    id: int
    name: str
    username: str
    email: str
    profile_image_url: str
    tier_id: int | None

class UserCreate(BaseModel):
    name: str
    username: str
    email: str
    password: str

Version 2 Schema (with changes)

# src/app/schemas/user_v2.py (new file)
from datetime import datetime

class UserReadV2(BaseModel):
    id: int
    name: str
    username: str
    email: str
    avatar_url: str          # Changed from profile_image_url
    subscription_tier: str   # Changed from tier_id to string
    created_at: datetime     # New field
    is_verified: bool        # New field

class UserCreateV2(BaseModel):
    name: str
    username: str
    email: str
    password: str
    accept_terms: bool       # New required field

Gradual Migration Strategy

1. Keep Both Versions Running

# Both versions work simultaneously
# v1: GET /api/v1/users/ -> list[UserRead]
# v2: GET /api/v2/users/ -> PaginatedListResponse[UserReadV2]

2. Add Deprecation Warnings

# src/app/api/v1/users.py
import warnings
from fastapi import HTTPException

@router.get("/", response_model=list[UserRead])
async def get_users(response: Response):
    # Add deprecation header
    response.headers["X-API-Deprecation"] = "v1 is deprecated. Use v2."
    response.headers["X-API-Sunset"] = "2024-12-31"  # When v1 will be removed

    users = await crud_users.get_multi(db=db, schema_to_select=UserRead)
    return users["data"]

3. Monitor Usage

Track which versions are being used:

# src/app/api/middleware.py
from fastapi import Request
import logging

logger = logging.getLogger(__name__)

async def version_tracking_middleware(request: Request, call_next):
    if request.url.path.startswith("/api/v1/"):
        logger.info(f"v1 usage: {request.method} {request.url.path}")
    elif request.url.path.startswith("/api/v2/"):
        logger.info(f"v2 usage: {request.method} {request.url.path}")

    response = await call_next(request)
    return response

Shared Code Between Versions

Keep common logic in shared modules:

Shared Dependencies

# src/app/api/dependencies.py - shared across all versions
async def get_current_user(...):
    # Authentication logic used by all versions
    pass

async def get_db():
    # Database connection used by all versions
    pass

Shared CRUD Operations

# The CRUD layer can be shared between versions
# Only the schemas and endpoints change

# v1 endpoint
@router.get("/", response_model=list[UserRead])
async def get_users_v1():
    users = await crud_users.get_multi(schema_to_select=UserRead)
    return users["data"]

# v2 endpoint  
@router.get("/", response_model=PaginatedListResponse[UserReadV2])
async def get_users_v2():
    users = await crud_users.get_multi(schema_to_select=UserReadV2)
    return paginated_response(users, page, items_per_page)

Version Discovery

Let clients discover available versions:

# src/app/api/versions.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/versions")
async def get_api_versions():
    return {
        "available_versions": ["v1", "v2"],
        "current_version": "v2",
        "deprecated_versions": [],
        "sunset_dates": {
            "v1": "2024-12-31"
        }
    }

Register it in main.py:

# src/app/main.py
from app.api.versions import router as versions_router

app.include_router(versions_router, prefix="/api")
# Now available at GET /api/versions

Testing Multiple Versions

Test both versions to ensure compatibility:

# tests/test_api_versioning.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_v1_users(client: AsyncClient):
    """Test v1 returns simple list"""
    response = await client.get("/api/v1/users/")
    assert response.status_code == 200

    data = response.json()
    assert isinstance(data, list)  # v1 returns list

@pytest.mark.asyncio  
async def test_v2_users(client: AsyncClient):
    """Test v2 returns paginated response"""
    response = await client.get("/api/v2/users/")
    assert response.status_code == 200

    data = response.json()
    assert "data" in data  # v2 returns paginated response
    assert "total_count" in data
    assert "page" in data

OpenAPI Documentation

Each version gets its own docs:

# src/app/main.py
from fastapi import FastAPI

# Create separate apps for documentation
v1_app = FastAPI(title="My API v1", version="1.0.0")
v2_app = FastAPI(title="My API v2", version="2.0.0")

# Register routes
v1_app.include_router(api_v1_router)
v2_app.include_router(api_v2_router)

# Mount as sub-applications
main_app = FastAPI()
main_app.mount("/api/v1", v1_app)
main_app.mount("/api/v2", v2_app)

Now you have separate documentation: - /api/v1/docs - v1 documentation - /api/v2/docs - v2 documentation

Best Practices

1. Semantic Versioning

  • v1.0v1.1: New features (backward compatible)
  • v1.1v2.0: Breaking changes (new version)

2. Clear Migration Path

# Document what changed in v2
"""
API v2 Changes:
- GET /users/ now returns paginated response instead of array
- POST /users/ now requires authentication
- UserRead.profile_image_url renamed to avatar_url
- UserRead.tier_id changed to subscription_tier (string)
- Added UserRead.created_at and is_verified fields
- UserCreate now requires accept_terms field
"""

3. Gradual Deprecation

  1. Release v2 alongside v1
  2. Add deprecation warnings to v1
  3. Set sunset date for v1
  4. Monitor v1 usage
  5. Remove v1 after sunset date

4. Consistent Patterns

Keep the same patterns across versions:

  • Same URL structure: /api/v{number}/resource
  • Same HTTP methods and status codes
  • Same authentication approach
  • Same error response format

What's Next

Now that you understand API versioning:

Proper versioning lets you evolve your API without breaking existing clients!