Every API eventually needs to change in ways that break existing clients. Field removed, response format changed, authentication updated. The question isn’t whether you’ll have breaking changes — it’s how you’ll manage them.

Good versioning gives you freedom to evolve while giving clients stability and migration paths.

The Three Schools of Versioning

URL Path Versioning

GGEETT//aappii//v12//uusseerrss//112233

Pros:

  • Obvious and explicit
  • Easy to route at load balancer level
  • Simple to document
  • Cache-friendly (different URLs = different cache keys)

Cons:

  • Version is part of resource identity
  • Hard to version individual endpoints differently
  • URL clutter

This is the most common approach and usually the right default choice.

Header Versioning

GAEcTce/patp:i/aupspelrisc/a1t2i3on/vnd.myapi.v2+json

Or custom header:

GXE-TAP/Ia-pVie/russieorns:/1223

Pros:

  • Clean URLs
  • Can version at granular level
  • RESTful purists prefer it

Cons:

  • Not visible in browser/logs without inspection
  • Harder to test manually
  • Caching more complex

Query Parameter Versioning

GET/api/users/123?version=2

Pros:

  • Visible in URL
  • Easy to switch versions for testing

Cons:

  • Pollutes query string
  • Optional parameter means default version logic needed
  • Caching complications

Implementation: URL Path Versioning

 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
27
28
from fastapi import FastAPI, APIRouter

app = FastAPI()

# Version 1
v1 = APIRouter(prefix="/api/v1")

@v1.get("/users/{user_id}")
def get_user_v1(user_id: int):
    return {"id": user_id, "name": "John", "email": "john@example.com"}

# Version 2 - different response shape
v2 = APIRouter(prefix="/api/v2")

@v2.get("/users/{user_id}")
def get_user_v2(user_id: int):
    return {
        "data": {
            "id": user_id,
            "attributes": {
                "name": "John",
                "email": "john@example.com"
            }
        }
    }

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

Semantic Versioning for APIs

Adapt semver to API context:

  • Major (v1 → v2): Breaking changes. New URL path.
  • Minor (v1.1 → v1.2): New endpoints, new optional fields. Backwards compatible.
  • Patch (v1.1.1 → v1.1.2): Bug fixes, documentation. No API changes.

Only major versions need new URL paths. Minor and patch versions are invisible to clients.

What Counts as Breaking?

Breaking changes (require new major version):

  • Removing a field from response
  • Removing an endpoint
  • Changing field type (string → integer)
  • Changing authentication mechanism
  • Changing error response format
  • Making optional field required

Non-breaking changes (safe in current version):

  • Adding new optional fields to response
  • Adding new endpoints
  • Adding new optional request parameters
  • Deprecation warnings
  • Performance improvements
1
2
3
4
5
6
7
8
# V1 response
{"id": 1, "name": "John"}

# V1.1 response - added field is non-breaking
{"id": 1, "name": "John", "created_at": "2026-01-15T10:00:00Z"}

# V2 response - restructured, this is breaking
{"data": {"id": 1, "name": "John"}}

Deprecation: The Migration Bridge

Never remove without warning:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from datetime import datetime
from fastapi import Response

@v1.get("/users/{user_id}")
def get_user_v1(user_id: int, response: Response):
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Sat, 01 Jun 2026 00:00:00 GMT"
    response.headers["Link"] = '</api/v2/users>; rel="successor-version"'
    
    return {"id": user_id, "name": "John"}

Standard headers:

  • Deprecation: true — This endpoint is deprecated
  • Sunset: <date> — When it will stop working
  • Link: <url>; rel="successor-version" — Where to migrate

Version Lifecycle

v1CURRENTvv12DCEUPRRREECNATTEDvvv123SDCUEUNPRSRREEETCNATTEDv2SUNSET

Typical timeline:

  1. v2 released: v1 still current, both supported
  2. v1 deprecated: v1 works but returns deprecation headers
  3. v1 sunset announced: Clear date communicated
  4. v1 removed: Returns 410 Gone

Minimum deprecation period depends on your clients. Enterprise APIs might need 12-24 months. Internal APIs might need 2 weeks.

Content Negotiation Alternative

For more granular versioning without URL changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from fastapi import Header, HTTPException

@app.get("/api/users/{user_id}")
def get_user(user_id: int, accept: str = Header(default="application/json")):
    if "vnd.myapi.v2" in accept:
        return {"data": {"id": user_id, "name": "John"}}
    elif "vnd.myapi.v1" in accept or accept == "application/json":
        return {"id": user_id, "name": "John"}
    else:
        raise HTTPException(406, "Unsupported media type")

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
@app.get("/api")
def api_info():
    return {
        "versions": [
            {
                "version": "v1",
                "status": "deprecated",
                "sunset": "2026-06-01",
                "url": "/api/v1"
            },
            {
                "version": "v2",
                "status": "current",
                "url": "/api/v2"
            }
        ],
        "current_version": "v2",
        "documentation": "https://docs.example.com/api"
    }

Default Version Behavior

What happens when no version specified?

Option 1: Require version (strict)

1
2
3
@app.get("/api/users/{user_id}")
def get_user_no_version():
    raise HTTPException(400, "API version required. Use /api/v1/ or /api/v2/")

Option 2: Default to latest (convenient but risky)

1
# /api/users/123 redirects to /api/v2/users/123

Option 3: Default to oldest stable (conservative)

1
# /api/users/123 redirects to /api/v1/users/123

Option 1 is safest. Clients must explicitly choose a version, preventing surprise breakage when you release v3.

Testing Across Versions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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/{api_version}/users/1")
    assert response.status_code == 200

def test_v1_response_shape(client):
    response = client.get("/api/v1/users/1")
    data = response.json()
    assert "id" in data
    assert "name" in data

def test_v2_response_shape(client):
    response = client.get("/api/v2/users/1")
    data = response.json()
    assert "data" in data
    assert "id" in data["data"]

Documentation Per Version

Each version needs its own documentation:

//ddooccss//v12//OOppeennAAPPIIssppeeccffoorrvv12

Clearly mark deprecated endpoints. Show migration guides. Include examples for both versions during transition periods.


API versioning is about respect for your clients. They built systems depending on your API. Breaking them without warning, without migration paths, without deprecation periods — that’s a betrayal of trust.

Version in the URL path for clarity. Communicate breaking changes early. Provide generous deprecation periods. Document everything. Your clients will thank you, and you’ll have the freedom to keep evolving your API.