Your API will change. How you handle that change determines whether clients curse your name or barely notice.

The Three Approaches

1. URL Path Versioning

GGEETTv12//uusseerrss//112233

Pros:

  • Obvious and explicit
  • Easy to route in any framework
  • Simple load balancer rules
  • Easy to deprecate (just turn off the route)

Cons:

  • URL changes when version changes
  • Cache keys differ per version
  • Feels “un-RESTful” to purists

This is the most common approach. It works.

2. Header Versioning

GAEcTce/puts:erasp/p1l2i3cation/vnd.myapi.v2+json

Or custom header:

GXE-TAP/Iu-sVeerrss/i1o2n3:2

Pros:

  • Clean URLs
  • Same resource, different representations
  • More “correct” REST interpretation

Cons:

  • Invisible in browser/logs
  • Harder to test (need to set headers)
  • Some clients don’t handle custom headers well

3. Query Parameter

GET/users/123?version=2

Pros:

  • Easy to test in browser
  • Obvious in logs

Cons:

  • Pollutes the URL
  • Query params are for filtering, not versioning
  • Cache complications

My Recommendation: URL Path

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# FastAPI example
from fastapi import APIRouter

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

@v1.get("/users/{user_id}")
def get_user_v1(user_id: int):
    return {"id": user_id, "name": "Alice"}

@v2.get("/users/{user_id}")
def get_user_v2(user_id: int):
    return {"id": user_id, "name": "Alice", "email": "alice@example.com"}

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

Clear, simple, works everywhere.

Version Numbering

Don’t use semver for APIs. Use integers.

v123///FBAirnreosattkhievnregrbscriheoaannkgiengchange

Semver implies patch compatibility that HTTP APIs can’t guarantee. Keep it simple.

What Counts as a Breaking Change?

Breaking (requires new version):

  • Removing a field
  • Renaming a field
  • Changing a field’s type
  • Changing response structure
  • Removing an endpoint
  • Changing authentication

Not breaking (same version is fine):

  • Adding a new optional field
  • Adding a new endpoint
  • Adding a new optional parameter
  • Increasing rate limits
  • Performance improvements

Deprecation Strategy

1
2
3
4
# Response headers for deprecation
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: <https://api.example.com/v3/docs>; rel="successor-version"

Timeline:

  1. Announce deprecation (6+ months before)
  2. Add deprecation headers
  3. Log usage of deprecated endpoints
  4. Contact heavy users directly
  5. Reduce rate limits on old version
  6. Return 410 Gone (final)

Supporting Multiple Versions

Approach A: Separate Codebases

//aappii--vv12//

Works for major rewrites. Painful for bug fixes.

Approach B: Version Adapters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Core logic is version-agnostic
def get_user_core(user_id: int) -> UserInternal:
    return db.get_user(user_id)

# Adapters transform to version-specific format
def to_v1_response(user: UserInternal) -> dict:
    return {"id": user.id, "name": user.name}

def to_v2_response(user: UserInternal) -> dict:
    return {"id": user.id, "name": user.name, "email": user.email}

One source of truth, multiple representations.

Approach C: Feature Flags

1
2
3
4
5
6
7
@app.get("/users/{user_id}")
def get_user(user_id: int, request: Request):
    user = get_user_core(user_id)
    
    if get_api_version(request) >= 2:
        return to_v2_response(user)
    return to_v1_response(user)

Gradual migration without separate routes.

Version Discovery

Let clients know what’s available:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
GET /

{
  "versions": {
    "v1": {
      "status": "deprecated",
      "sunset": "2027-01-01",
      "docs": "https://api.example.com/v1/docs"
    },
    "v2": {
      "status": "current",
      "docs": "https://api.example.com/v2/docs"
    },
    "v3": {
      "status": "beta",
      "docs": "https://api.example.com/v3/docs"
    }
  }
}

Testing Across Versions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import pytest

@pytest.fixture(params=["v1", "v2"])
def api_version(request):
    return request.param

def test_user_endpoint_exists(client, api_version):
    response = client.get(f"/{api_version}/users/1")
    assert response.status_code == 200

def test_v2_has_email(client):
    response = client.get("/v2/users/1")
    assert "email" in response.json()

def test_v1_no_email(client):
    response = client.get("/v1/users/1")
    assert "email" not in response.json()

The Philosophy

  1. Default to the latest version for new clients
  2. Support at least one previous version in production
  3. Deprecate loudly, remove quietly
  4. Breaking changes should be rare (additive changes usually suffice)
  5. Your version strategy should be decided before v1 ships

The best versioning strategy is the one that lets your clients sleep at night.