Skip to main content
โšก Calmops

API Versioning Strategies: Managing Breaking Changes

APIs are contracts with your users. When you need to change them, you have options. This guide covers versioning strategies, their trade-offs, and how to manage breaking changes without hurting your users.

Why Version?

APIs evolve. Features get added, behaviors change, fields get renamed. Without versioning, every change could break existing clients. Versioning lets you:

  • Add features without breaking existing clients
  • Maintain backward compatibility
  • Give users time to migrate
  • Support multiple client versions simultaneously

Versioning Strategies

1. URL Path Versioning

Most common approach. Version in the URL path.

GET /api/v1/users
GET /api/v2/users

Implementation:

from fastapi import FastAPI, APIRouter
from enum import Enum

app = FastAPI()

v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")

@v1_router.get("/users")
def get_users_v1():
    return [{"name": "John", "email": "[email protected]"}]

@v2_router.get("/users")
def get_users_v2():
    return [
        {
            "id": "user-1",
            "full_name": "John Doe",
            "email": "[email protected]",
            "created_at": "2024-01-15T10:00:00Z"
        }
    ]

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

Pros:

  • Easy to understand
  • Visible in every request
  • Easy to route to different handlers
  • Cache-friendly

Cons:

  • URL changes on every version
  • Duplication if handlers are similar

2. Header Versioning

Version in HTTP header.

GET /api/users
Accept-Version: v1

GET /api/users
Accept-Version: v2

Implementation:

from fastapi import FastAPI, Header

app = FastAPI()

@app.get("/users")
def get_users(accept_version: str = Header(default="v1")):
    if accept_version == "v2":
        return get_users_v2()
    return get_users_v1()

Or using Accept header with media types:

GET /api/users
Accept: application/vnd.example.v2+json
from fastapi import FastAPI, Header

@app.get("/users")
def get_users(
    accept: str = Header(default="application/json")
):
    if "v2" in accept:
        return get_users_v2()
    return get_users_v1()

Pros:

  • Clean URLs
  • Multiple versions same URL

Cons:

  • Less visible
  • More complex routing
  • Harder to test

3. Query Parameter Versioning

Version as query parameter.

GET /api/users?version=1
GET /api/users?version=2

Implementation:

from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/users")
def get_users(version: int = Query(default=1, ge=1, le=2)):
    if version == 2:
        return get_users_v2()
    return get_users_v1()

Pros:

  • Single endpoint
  • Easy to test

Cons:

  • Query params often cached with wrong version
  • Cumbersome for simple endpoints

Strategy Comparison

Strategy URL Clear Cache Client Easy Complex
URL Path Yes Yes Yes Low
Header No Maybe No Medium
Query Param No No Yes Low

Recommendation: Use URL path versioning for most APIs. It’s the clearest and most straightforward.

Semantic Versioning for APIs

Follow semantic versioning principles:

vMAJOR.MINOR.PATCH

MAJOR - Breaking changes
MINOR - New features (backward compatible)
PATCH - Bug fixes
# API version as enum
from enum import Enum

class APIVersion(Enum):
    V1_0 = "1.0.0"  # Initial release
    V1_1 = "1.1.0"  # Added new field
    V1_2 = "1.2.0"  # Added endpoint
    V2_0 = "2.0.0"  # Breaking changes

Breaking vs Non-Breaking Changes

Non-Breaking Changes (MINOR)

  • Adding new optional fields
  • Adding new endpoints
  • Adding new response fields
  • Changing field order
  • Adding new enum values
# v1 response
{"name": "John", "email": "[email protected]"}

# v1.1 response - adding new field (non-breaking)
{"name": "John", "email": "[email protected]", "avatar_url": null}

# v1.2 response - adding endpoint (non-breaking)
# New endpoint: GET /users/{id}/orders

Breaking Changes (MAJOR)

  • Removing fields
  • Renaming fields
  • Changing field types
  • Changing response format
  • Removing endpoints
  • Changing validation rules
  • Changing authentication requirements
# v1 response
{"name": "John", "email": "[email protected]"}

# v2 - breaking: renamed fields
{"full_name": "John Doe", "contact_email": "[email protected]"}

# v2 - breaking: changed type
{"name": "John", "email": "[email protected]", "age": "25"}  # was integer

Deprecation Strategy

Announce Early

HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
from fastapi import FastAPI, Response
from datetime import datetime, timedelta

@app.get("/users")
def get_users(response: Response):
    # Add deprecation headers 6 months before removing
    if is_deprecated("v1"):
        response.headers["Deprecation"] = "true"
        sunset_date = datetime.utcnow() + timedelta(days=180)
        response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
        response.headers["Link"] = '</v2/users>; rel="successor-version"'
    
    return get_users_v1()

Grace Period Timeline

Month 1: Announce deprecation, no timeline
Month 3: Set sunset date 6 months out
Month 6: Add deprecation warnings in responses
Month 9: Start returning 410 Gone for deprecated endpoints
Month 12: Remove deprecated version

Deprecation Response Format

{
  "error": "deprecated",
  "message": "This endpoint is deprecated",
  "sunset_date": "2024-06-01T00:00:00Z",
  "documentation": "https://docs.example.com/migration-guide",
  "successor": "/v2/users"
}

Implementing Version Negotiation

Version Detection

from fastapi import FastAPI, Request
from typing import Optional

def get_api_version(request: Request) -> str:
    # Check URL path first
    path = request.url.path
    if "/v2/" in path:
        return "v2"
    if "/v1/" in path:
        return "v1"
    
    # Check header
    version = request.headers.get("Accept-Version")
    if version:
        return version
    
    # Check query param
    version = request.query_params.get("version")
    if version:
        return f"v{version}"
    
    # Default to latest stable
    return "v2"

Version-Specific Response Schemas

from pydantic import BaseModel
from typing import Optional
from datetime import datetime

class UserV1(BaseModel):
    name: str
    email: str

class UserV2(BaseModel):
    id: str
    full_name: str
    email: str
    avatar_url: Optional[str] = None
    created_at: datetime

@app.get("/users/{user_id}")
def get_user(user_id: str, version: str = "v2"):
    user = user_service.get(user_id)
    
    if version == "v1":
        return UserV1(name=user.name, email=user.email)
    
    return UserV2(
        id=user.id,
        full_name=user.full_name,
        email=user.email,
        avatar_url=user.avatar_url,
        created_at=user.created_at
    )

Migration Strategies

Parallel Running

Run old and new versions simultaneously:

# Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-v1
spec:
  replicas: 3
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-v2
spec:
  replicas: 5  # More capacity for new version
# Traffic splitting
@app.middleware("http")
async def route_to_version(request: Request, call_next):
    if is_deprecated(request):
        # Route to v1 with smaller percentage
        if random.random() > 0.1:
            request.url.path = request.url.path.replace("/v1/", "/v2/")
    
    return await call_next(request)

Feature Flags for Users

# Let users opt-in to new version
class User:
    def __init__(self):
        self.api_version_preference = "v1"  # or "v2-beta"

@app.get("/users/me")
def get_current_user(user: User = Depends(get_current_user)):
    version = user.api_version_preference
    return get_users(version=version)

Feature Flags in Code

from featureflags import flag

@app.get("/users")
@flag("new-user-endpoint")
def get_users():
    if flag.enabled("new-user-endpoint"):
        return get_users_v2()
    return get_users_v1()

Response Format Versioning

Embed Version in Response

{
  "version": "2.0",
  "data": {...}
}

Using JSON:API Style

{
  "jsonapi": {
    "version": "1.0"
  },
  "data": {...}
}

Best Practices

Do

  • Version from day one
  • Document changes clearly
  • Maintain backward compatibility within major versions
  • Provide clear migration guides
  • Use deprecation headers
  • Support multiple versions during transitions

Don’t

  • Make breaking changes without versioning
  • Remove versions without notice
  • Force clients to upgrade
  • Version for every small change
  • Break the principle of least surprise

Migration Checklist

When releasing a new major version:

  • Document all breaking changes
  • Create migration guide
  • Update API documentation
  • Set deprecation headers on old version
  • Notify API consumers
  • Run parallel systems during transition
  • Monitor both versions
  • Set sunset date
  • Remove old version after grace period

Conclusion

API versioning is not optional - it’s how you evolve without breaking your users:

  • Use URL path versioning for clarity
  • Follow semantic versioning
  • Give users 6-12 months to migrate
  • Always announce deprecations early
  • Maintain backward compatibility within versions
  • Document everything

External Resources

Comments