You shipped v1 of your API. Clients integrated. Now you need to make breaking changes. How do you evolve without breaking everyone?

API versioning is the answer—but there’s no single “right” approach. Let’s examine the tradeoffs.

What Counts as a Breaking Change?

Before versioning, understand what actually breaks clients:

Breaking changes:

  • Removing a field from responses
  • Removing an endpoint
  • Changing a field’s type ("price": "19.99""price": 19.99)
  • Renaming a field
  • Changing required request parameters
  • Changing authentication methods

Non-breaking changes:

  • Adding new optional fields to responses
  • Adding new endpoints
  • Adding optional request parameters
  • Adding new enum values (usually)
1
2
3
4
5
6
7
8
// v1 response
{"id": 1, "name": "Widget"}

// v1.1 response (non-breaking: added field)
{"id": 1, "name": "Widget", "created_at": "2026-03-04"}

// v2 response (breaking: removed field, changed type)
{"id": "uuid-123", "title": "Widget"}

Strategy 1: URL Path Versioning

The most visible and common approach:

GGEETT//aappii//v12//uusseerrss

Implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from fastapi import APIRouter

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

@v1_router.get("/users/{user_id}")
def get_user_v1(user_id: int):
    user = db.get_user(user_id)
    return {"id": user.id, "name": user.name}  # v1 format

@v2_router.get("/users/{user_id}")
def get_user_v2(user_id: int):
    user = db.get_user(user_id)
    return {
        "id": str(user.uuid),  # v2: UUIDs instead of ints
        "display_name": user.name,  # v2: renamed field
        "created_at": user.created_at.isoformat()
    }

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

Pros:

  • Crystal clear which version you’re using
  • Easy to route at load balancer/CDN level
  • Simple to document and test
  • Cache-friendly (different URLs = different cache keys)

Cons:

  • URLs feel “cluttered”
  • Encourages big-bang version bumps instead of gradual evolution
  • Can lead to significant code duplication

Strategy 2: Header Versioning

Version in request headers, cleaner URLs:

GAEcTce/patp:i/aupspelriscation/vnd.myapi.v2+json

Or with a custom header:

GXE-TAP/Ia-pVie/russieorns:2

Implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from fastapi import Header, HTTPException

@app.get("/api/users/{user_id}")
def get_user(user_id: int, x_api_version: int = Header(default=1)):
    user = db.get_user(user_id)
    
    if x_api_version == 1:
        return {"id": user.id, "name": user.name}
    elif x_api_version == 2:
        return {
            "id": str(user.uuid),
            "display_name": user.name,
            "created_at": user.created_at.isoformat()
        }
    else:
        raise HTTPException(400, f"Unsupported API version: {x_api_version}")

Pros:

  • Clean, resource-focused URLs
  • Follows REST principles (URL = resource, headers = metadata)
  • Easier to default to latest version

Cons:

  • Less discoverable (version hidden in headers)
  • Harder to test in browser
  • Some proxies/caches don’t vary on custom headers properly
  • Clients must remember to set headers

Strategy 3: Query Parameter Versioning

Version as a query parameter:

GET/api/users?version=2

Implementation:

1
2
3
4
5
6
7
8
@app.get("/api/users/{user_id}")
def get_user(user_id: int, version: int = 1):
    user = db.get_user(user_id)
    
    if version == 1:
        return {"id": user.id, "name": user.name}
    elif version == 2:
        return {"id": str(user.uuid), "display_name": user.name}

Pros:

  • Easy to test (just add ?version=2 in browser)
  • No header configuration needed
  • Works everywhere

Cons:

  • Pollutes query string
  • Easy to forget (leads to accidental version mismatches)
  • Mixes resource identification with versioning concerns

Strategy 4: No Explicit Versions (Evolutionary Design)

Instead of versions, design for backward compatibility from day one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    user = db.get_user(user_id)
    return {
        "id": user.id,           # Keep forever
        "uuid": str(user.uuid),  # Added in 2024
        "name": user.name,       # Keep forever
        "display_name": user.name,  # Added in 2025, preferred
        "created_at": user.created_at.isoformat()  # Added in 2024
    }

Rules for evolutionary APIs:

  1. Never remove fields (deprecate instead)
  2. Never change field types
  3. Never rename fields (add new, keep old)
  4. Make new request parameters optional with sensible defaults

Pros:

  • No version management overhead
  • Clients upgrade gradually
  • One codebase to maintain

Cons:

  • Response payloads grow over time
  • Can’t fix past mistakes
  • Eventually accumulates cruft

Hybrid Approach: Major + Minor Versions

Use URL versioning for major breaking changes, evolutionary design within versions:

//aappii//v12//uusseerrssvv12..00,,vv12..11,(vb1r.e2ak(ianlglcbhaacnkgweasrdfrcoommpva1t)ible)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# v1 evolves without breaking
@v1_router.get("/users/{user_id}")
def get_user_v1(user_id: int):
    return {
        "id": user.id,
        "name": user.name,
        "email": user.email,  # Added in v1.1
        "avatar_url": user.avatar_url,  # Added in v1.2
    }

# v2 only when absolutely necessary
@v2_router.get("/users/{user_id}")
def get_user_v2(user_id: int):
    return {
        "uuid": str(user.uuid),  # Breaking: replaced int id
        "profile": {  # Breaking: restructured
            "name": user.name,
            "email": user.email,
        }
    }

Version Deprecation

Don’t maintain old versions forever. Establish a policy:

 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
from datetime import datetime
from fastapi import Header, HTTPException

DEPRECATION_DATES = {
    1: datetime(2025, 6, 1),  # v1 deprecated June 2025
    2: None,  # v2 current
}

SUNSET_DATES = {
    1: datetime(2025, 12, 1),  # v1 removed Dec 2025
}

@app.middleware("http")
async def version_warnings(request, call_next):
    response = await call_next(request)
    
    version = int(request.headers.get("X-API-Version", 2))
    
    if version in DEPRECATION_DATES and DEPRECATION_DATES[version]:
        response.headers["Deprecation"] = DEPRECATION_DATES[version].isoformat()
        response.headers["Link"] = '</api/v2>; rel="successor-version"'
    
    if version in SUNSET_DATES and SUNSET_DATES[version]:
        response.headers["Sunset"] = SUNSET_DATES[version].isoformat()
    
    return response

Communicate clearly:

  • Deprecation: “This version is old, please migrate”
  • Sunset: “This version will stop working on X date”

Documentation Per Version

Each version needs its own docs:

1
2
3
4
5
6
7
# OpenAPI spec with version
openapi: 3.0.0
info:
  title: My API
  version: "2.0.0"
servers:
  - url: https://api.example.com/v2

Tools like Swagger UI can host multiple versions:

//ddooccss//v12vv12AAPPIIrreeffeerreennccee

My Recommendation

For most APIs:

  1. Start with URL versioning (/api/v1/) — it’s explicit and well-understood
  2. Use evolutionary design within versions — don’t bump for every change
  3. Bump major versions rarely — only for fundamental restructuring
  4. Set clear deprecation timelines — 6-12 months notice minimum
  5. Monitor version usage — know when it’s safe to sunset
1
2
3
4
5
6
# Track which versions clients use
@app.middleware("http")
async def track_version(request, call_next):
    version = extract_version(request.url.path)
    metrics.increment("api_requests", tags={"version": version})
    return await call_next(request)

When you see v1 traffic drop to near zero, you can safely remove it.


The best API version is the one your clients don’t have to think about. Make upgrades easy, deprecations gentle, and breaking changes rare.