Your API will change. Features will be added, mistakes will be corrected, and sometimes you’ll need to break things. The question isn’t whether to version — it’s how.

Why Version?

Breaking changes are changes that make existing clients fail:

  • Removing a field from a response
  • Changing a field’s type (string → integer)
  • Requiring a new parameter
  • Changing the meaning of a value
  • Removing an endpoint

Without versioning, every change risks breaking someone’s integration. With versioning, you can evolve the API while giving clients time to adapt.

Versioning Strategies

URL Path Versioning

The most visible approach: version in the URL.

GGEETTv12//uusseerrss//112233

Pros:

  • Obvious and explicit
  • Easy to route at the load balancer level
  • Clients know exactly what version they’re using

Cons:

  • URL changes when version changes
  • Can’t easily version individual endpoints differently
  • “Feels” like different APIs

Implementation:

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

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

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

@v2_router.get("/users/{id}")
def get_user_v2(id: int):
    return {"id": id, "full_name": "Alice Smith", "email": "alice@example.com"}  # v2 response

Header Versioning

Version specified in a custom header.

GAEcTce/puts-eVresr/s1i2o3n:v2

Or using content negotiation:

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

Pros:

  • Clean URLs
  • Version individual resources differently
  • More RESTful (same resource, different representation)

Cons:

  • Less visible (easy to forget)
  • Harder to test in browser
  • Some proxies/caches don’t handle custom headers well

Implementation:

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

@app.get("/users/{id}")
def get_user(id: int, accept_version: str = Header(default="v1")):
    if accept_version == "v2":
        return {"id": id, "full_name": "Alice Smith"}
    elif accept_version == "v1":
        return {"id": id, "name": "Alice"}
    else:
        raise HTTPException(400, "Unknown API version")

Query Parameter Versioning

Version as a query parameter.

GET/users/123?version=2

Pros:

  • Easy to test
  • Visible in URLs
  • Simple to implement

Cons:

  • Clutters query string
  • Easy to forget
  • Mixes versioning with business parameters

Generally less preferred, but works fine for simple APIs.

Date-Based Versioning (Stripe Style)

Version by API release date.

GSEtTri/pues-eVresr/s1i2o3n:2024-02-01

Pros:

  • Clear timeline of changes
  • Easy to pin to “last known working”
  • Can handle frequent changes

Cons:

  • Requires tracking what changed on each date
  • More complex backend logic

Stripe’s approach:

  • Account has a default API version
  • Each request can override with header
  • Webhooks sent using account’s version
  • Dashboard shows version changelog

Managing Multiple Versions

The Adapter Pattern

Keep internal logic in the latest format, adapt at the edges:

 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
# Internal model - always latest
class UserInternal:
    id: int
    full_name: str
    email: str
    created_at: datetime

# Version adapters
def user_to_v1(user: UserInternal) -> dict:
    return {
        "id": user.id,
        "name": user.full_name.split()[0]  # v1 only had first name
    }

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

@app.get("/users/{id}")
def get_user(id: int, version: str = "v2"):
    user = fetch_user(id)  # Returns UserInternal
    
    if version == "v1":
        return user_to_v1(user)
    return user_to_v2(user)

Deprecation Headers

Signal that a version is going away:

1
2
3
4
5
6
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"

{"id": 123, "name": "Alice"}

Clients can monitor these headers and plan migrations.

Version Lifecycle

Define clear stages:

  1. Current: Active development, new features
  2. Supported: Bug fixes and security patches only
  3. Deprecated: Works but will be removed, warnings sent
  4. Sunset: Gone, returns 410 Gone or redirects

Example timeline:

vv12::RDSRCeeueulpnlrerseraeeaesctsnea:etdtd:eJJduJNa:laonnwJ22a020n202423240(2148(m1o2ntmhosntthost)al)

Avoiding Breaking Changes

The best version bump is the one you don’t need.

Additive Changes (Safe)

These don’t require a new version:

  • Adding optional fields to responses
  • Adding optional parameters to requests
  • Adding new endpoints
  • Adding new enum values (if clients ignore unknown)
1
2
3
4
5
// v1 response
{"id": 123, "name": "Alice"}

// v1 response with new optional field (still v1!)
{"id": 123, "name": "Alice", "avatar_url": "https://..."}

Nullable Fields

Make fields nullable instead of removing them:

1
2
3
4
5
// Don't remove "nickname"
{"id": 123, "name": "Alice"}

// Mark it null
{"id": 123, "name": "Alice", "nickname": null}

Field Aliasing

Support both old and new names during transition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@app.post("/users")
def create_user(data: dict):
    # Accept both "name" (old) and "full_name" (new)
    name = data.get("full_name") or data.get("name")
    ...

@app.get("/users/{id}")
def get_user(id: int):
    user = fetch_user(id)
    return {
        "id": user.id,
        "name": user.full_name,      # Old field
        "full_name": user.full_name  # New field
    }

Expand Instead of Change

Instead of changing a field’s type:

1
2
3
4
5
6
// Bad: changing "address" from string to object
v1: {"address": "123 Main St"}
v2: {"address": {"street": "123 Main St", "city": "NYC"}}

// Good: new field, deprecate old
{"address": "123 Main St", "address_details": {"street": "123 Main St", "city": "NYC"}}

Documentation

Every version needs:

  1. Changelog: What’s different from previous version
  2. Migration guide: How to upgrade
  3. Deprecation notices: What’s going away and when
  4. API reference: Complete docs for each supported version
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
## Changelog: v2 (2024-02-01)

### Breaking Changes
- `GET /users/{id}`: Field `name` renamed to `full_name`
- `POST /orders`: Field `items` now required (was optional)

### New Features
- `GET /users/{id}`: Added `email` field
- New endpoint: `GET /users/{id}/preferences`

### Deprecations
- `GET /users/{id}`: Field `name` deprecated, use `full_name`
  (will be removed in v3)

### Migration Guide
1. Update client to use `full_name` instead of `name`
2. If using `POST /orders`, ensure `items` is always provided

Testing Multiple Versions

 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
import pytest

@pytest.fixture
def client():
    return TestClient(app)

def test_user_v1(client):
    response = client.get("/v1/users/123")
    data = response.json()
    
    assert "name" in data
    assert "full_name" not in data  # v1 shouldn't have this

def test_user_v2(client):
    response = client.get("/v2/users/123")
    data = response.json()
    
    assert "full_name" in data
    assert "email" in data

def test_v1_deprecation_header(client):
    response = client.get("/v1/users/123")
    
    assert response.headers.get("Deprecation") == "true"
    assert "Sunset" in response.headers

Practical Recommendations

  1. Start with URL versioning — It’s the most explicit and easiest to understand

  2. Version the whole API — Don’t version individual endpoints differently unless you have a specific reason

  3. Support at least 2 versions — Current and previous, with overlap

  4. 12+ months deprecation window — Give clients time to migrate

  5. Make breaking changes rare — Use additive changes and field aliasing when possible

  6. Monitor version usage — Know when it’s safe to sunset

  7. Communicate proactively — Email, changelog, deprecation headers, dashboard notices


API versioning is a contract with your users. Do it thoughtfully, communicate clearly, and remember: the goal isn’t to never change — it’s to change without surprises.