API versioning strategies — picking the right approach
← Back
April 4, 2026Architecture7 min read

API versioning strategies — picking the right approach

Published April 4, 20267 min read

API versioning is one of those decisions you make once and live with for years. Pick the wrong strategy and you end up with a proliferation of dead versions in your codebase, confused consumers, or both. There is no universally correct answer — but there are clear tradeoffs, and the right choice depends on who your consumers are and how they deploy.

Strategy 1: URL path versioning

/v1/users, /v2/users — the most common and most visible approach.

python
# FastAPI example
from fastapi import APIRouter

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

@v1_router.get("/users/{id}")
def get_user_v1(id: str):
    # Old response shape
    return {"user_id": id, "name": "Alice"}

@v2_router.get("/users/{id}")
def get_user_v2(id: str):
    # New response shape with additional fields
    return {
        "id": id,           # renamed from user_id
        "name": "Alice",
        "created_at": "2026-01-01T00:00:00Z",
        "metadata": {},
    }

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

Pros: Explicit, cache-friendly (URLs are different), easy to route at the load balancer level, easy to sunset (delete the route).
Cons: Versioning is "all or nothing" — if you change one field in one endpoint, you version the entire API. Can lead to duplication.

Best for: Public APIs with external consumers who deploy on their own schedule. Mobile apps that cannot be force-updated.

Strategy 2: Header versioning

The version is passed in a request header: API-Version: 2 or Accept: application/vnd.myapi.v2+json.

python
# FastAPI with header versioning
from fastapi import Header, HTTPException

@app.get("/users/{id}")
def get_user(
    id: str,
    api_version: str = Header(default="1", alias="API-Version"),
):
    if api_version == "2":
        return get_user_v2_response(id)
    elif api_version == "1":
        return get_user_v1_response(id)
    else:
        raise HTTPException(400, f"Unsupported API version: {api_version}")

Pros: Clean URLs (version does not pollute the path), same URL works across versions.
Cons: Not cache-friendly (same URL, different responses), harder to test in browser/curl, consumers must remember to set the header.

Best for: Internal APIs consumed by services you control, where you can enforce header usage.

Strategy 3: Content negotiation (Accept header)

python
# Accept: application/vnd.myapi+json; version=2
@app.get("/users/{id}")
def get_user(id: str, accept: str = Header(default="")):
    version = parse_version_from_accept(accept)  # extract version=2 from Accept header
    if version == 2:
        return get_user_v2_response(id)
    return get_user_v1_response(id)

def parse_version_from_accept(accept: str) -> int:
    import re
    match = re.search(r'version=(d+)', accept)
    return int(match.group(1)) if match else 1

Pros: Standards-compliant, elegant for resource-oriented APIs.
Cons: Verbose, unfamiliar to many developers, hard to test without proper HTTP clients.

Best for: APIs that strictly follow REST principles and have technically sophisticated consumers.

Strategy 4: Field-level versioning with deprecation flags

Instead of versioning the API, deprecate individual fields and add new ones. This avoids the overhead of maintaining parallel route implementations:

python
# Response includes old and new fields during transition period
def get_user_response(user: User) -> dict:
    return {
        "id": user.id,
        "name": user.name,
        # Deprecated: will be removed in 6 months
        "user_id": user.id,    # kept for backward compatibility

        # New field
        "created_at": user.created_at.isoformat(),
    }

# Set a deprecation header
@app.get("/users/{id}")
def get_user(id: str, response: Response):
    user = get_user_by_id(id)
    if uses_deprecated_field(request):
        response.headers["Deprecation"] = "true"
        response.headers["Sunset"] = "Sat, 01 Jan 2027 00:00:00 GMT"
    return get_user_response(user)

Pros: No parallel route maintenance, consumers migrate at their own pace, granular deprecation.
Cons: Response bloat during transition, requires discipline to remove deprecated fields on schedule.

Best for: APIs with well-behaved internal consumers who you can notify and track.

Choosing the right strategy

Consumer typeRecommended strategy
Public third-party developersURL path versioning
Mobile apps you do not controlURL path versioning
Internal servicesHeader versioning or field deprecation
Same-team frontendField deprecation with short timelines
Partner integrations with SLAsURL path versioning with formal sunset dates

Version sunset policy

The most important policy to establish before launching v1:

markdown
# API Version Lifecycle Policy

- Each major version is supported for **18 months** after the next major version launches
- 6 months notice before sunset (Sunset header + email to registered consumers)
- Traffic monitoring: if >1% of traffic still on deprecated version at sunset date, extend by 3 months
- No minimum support for beta/preview versions

Without a sunset policy, old versions live forever. With one, you can confidently delete code and reduce maintenance burden on a schedule.

The one thing most teams get wrong

Starting with v2 "for flexibility." Do not. Start with a versionless API (/users, not /v1/users). When you need to break compatibility, that becomes your v1 and you introduce v2. Starting at v1 immediately signals to consumers that breaking changes are coming — which increases their anxiety and often leads to premature migration.

Share this
← All Posts7 min read