API Versioning and Backward Compatibility

Evolving APIs without breaking existing clients. The hardest constraint in API design: you can add anything, but removing or changing existing behaviour will break someone.

Evolving APIs without breaking existing clients. The hardest constraint in API design: you can add anything, but removing or changing existing behaviour will break someone.


Versioning Strategies

StrategyExampleProsCons
URL path/v1/productsExplicit, easy to documentURL pollution, branching
HeaderAPI-Version: 2024-11-01Clean URLsLess visible, harder to test
Query param?version=2Easy to testCaching complexity
Content negotiationAccept: application/vnd.myapp.v2+jsonHTTP standardMost complex
Date-based2024-11-01Intuitive changelogNever-ending versions

Recommendation: URL path versioning for REST APIs. Simple, visible, easy to route.


What Counts as a Breaking Change

Breaking (never do without a major version bump):
  - Remove a field from a response
  - Change a field's type (string → int)
  - Change a field's name
  - Remove an endpoint
  - Change required HTTP method
  - Make an optional field required
  - Change URL structure
  - Remove or rename a query parameter
  - Change error response format

Non-breaking (safe to ship without version bump):
  - Add a new optional field to a response
  - Add a new optional query parameter
  - Add a new endpoint
  - Make a required field optional
  - Add a new enum value (careful — strict clients may reject)
  - Return more data than before
  - Reduce latency
  - Fix bugs that were never part of the contract

URL Path Versioning

# FastAPI — versioned routers
from fastapi import FastAPI, APIRouter

app = FastAPI()

# v1 router
v1 = APIRouter(prefix="/v1", tags=["v1"])

@v1.get("/products")
def list_products_v1():
    return [{"id": "p1", "name": "Widget", "price": 9.99}]

@v1.get("/products/{id}")
def get_product_v1(id: str):
    return {"id": id, "name": "Widget", "price": 9.99}

# v2 router — extended response
v2 = APIRouter(prefix="/v2", tags=["v2"])

@v2.get("/products")
def list_products_v2():
    return [{"id": "p1", "name": "Widget", "price": 9.99, "currency": "GBP",
             "in_stock": True, "category": {"id": "cat1", "name": "Gadgets"}}]

app.include_router(v1)
app.include_router(v2)

Header Versioning (Date-Based)

# Stripe-style: API-Version header with date
from fastapi import Header, Request
from datetime import date

def get_api_version(api_version: str = Header(default="2024-01-01")) -> date:
    try:
        return date.fromisoformat(api_version)
    except ValueError:
        raise HTTPException(422, "Invalid API-Version header format (YYYY-MM-DD)")

@app.get("/products/{id}")
def get_product(id: str, api_version: date = Depends(get_api_version)):
    product = fetch_product(id)

    if api_version >= date(2024, 11, 1):
        # New format with nested category object
        return {"id": product.id, "name": product.name,
                "category": {"id": product.category_id, "name": product.category_name}}
    else:
        # Old format with flat category_id
        return {"id": product.id, "name": product.name,
                "category_id": product.category_id}

Deprecation Communication

import warnings
from datetime import date

DEPRECATION_SUNSET = date(2025, 6, 1)

@v1.get("/products")
def list_products_v1(response: Response):
    # Signal deprecation via standard headers
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Sat, 01 Jun 2025 00:00:00 GMT"
    response.headers["Link"] = '</v2/products>; rel="successor-version"'
    # Optionally: Deprecation: Wed, 01 Jan 2025 00:00:00 GMT (when deprecated)

    return legacy_product_list()

# Log who is still using v1 — reach out before sunset
@v1.middleware("http")
async def log_v1_usage(request: Request, call_next):
    response = await call_next(request)
    logger.warning("v1 API called",
                   path=request.url.path,
                   client_id=request.headers.get("X-Client-ID"),
                   days_until_sunset=(DEPRECATION_SUNSET - date.today()).days)
    return response

Backward-Compatible Field Addition

# Use Optional + default None for new fields — never break old clients
from pydantic import BaseModel
from typing import Optional

# v1 model
class ProductV1(BaseModel):
    id: str
    name: str
    price: float

# v2 model — adds fields without breaking v1 consumers
class ProductV2(BaseModel):
    id: str
    name: str
    price: float
    currency: str = "GBP"          # default — old clients ignore it
    in_stock: Optional[bool] = None  # None = unknown; old clients ignore
    tags: list[str] = []            # empty list default

# Additive schema evolution in JSON Schema / OpenAPI
# Never change: required fields, field types, field names
# Always OK: add new optional fields, add new non-breaking enum values

Version Testing Strategy

# tests/test_backward_compatibility.py
# Run v1 tests against v2 endpoint — v2 must be a superset

@pytest.mark.parametrize("version", ["/v1", "/v2"])
def test_product_list_returns_expected_fields(client, version):
    response = client.get(f"{version}/products")
    assert response.status_code == 200
    products = response.json()["data"]
    for p in products:
        # v1 contract: these fields must always be present
        assert "id" in p
        assert "name" in p
        assert "price" in p

def test_v1_client_works_against_v2(client):
    """Simulate a v1 client making requests to v2."""
    v1_client_fields = {"id", "name", "price"}
    response = client.get("/v2/products")
    for p in response.json()["data"]:
        # All v1 fields must be present in v2 response
        assert v1_client_fields.issubset(set(p.keys()))

Common Failure Cases

Silently removing a field and breaking existing clients Why: the field looks unused in internal monitoring, but external or partner clients depend on it without advertising that dependency. Detect: add a deprecation header to the field for one full release cycle and log callers who still request it; only remove when traffic reaches zero. Fix: treat field removal as a major version bump and communicate a sunset date at least 90 days in advance via Deprecation and Sunset headers.

Adding a new required enum value that strict clients reject Why: adding a value to an existing enum is considered non-breaking internally, but clients that deserialise into exhaustive enums (TypeScript, Rust) will throw on the unknown variant. Detect: review client SDK code for exhaustive enum handling; send the new value to client test environments before rollout. Fix: document that enum additions are semi-breaking; give clients the new value in a beta header before making it live in the stable version.

v1 API silently kept alive past its sunset date Why: no automated enforcement exists; the sunset date passes but traffic continues and the deadline is quietly ignored. Detect: v1_usage middleware log shows ongoing traffic after the sunset date. Fix: after the sunset date, return 410 Gone with a Link header pointing to the successor version, and enforce it via a gateway rule rather than relying on manual process.

Version sprawl from date-based versioning with no deprecation policy Why: a new date-stamped version is created for every small change; after two years there are dozens of active versions requiring maintenance. Detect: count distinct version values seen in API access logs over the past 30 days. Fix: adopt a formal deprecation policy with a minimum support window (e.g., 12 months), enforce it in CI, and cap the number of simultaneously supported versions.

Connections

se-hub · cs-fundamentals/api-design · cs-fundamentals/microservices-patterns · cs-fundamentals/graphql-se · cs-fundamentals/grpc · qa/defect-prevention

Open Questions

  • What are the most common misapplications of this concept in production codebases?
  • When should you explicitly choose not to use this pattern or technique?