C.W.K.
Stream
Lesson 05 of 06 · published

Versioning Strategies — How APIs Evolve Without Breaking Clients

~11 min · semantics, versioning, evolution, deprecation

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Your API will change. Either you version it on purpose, or you break clients by accident. Pick a strategy on day one, even if you don't ship v2 for years."

The Problem Versioning Solves

APIs accumulate decisions: field names, error formats, status code choices, default values. Some of those decisions turn out wrong. Some markets change underneath. You want to fix or extend the API without breaking the 10,000 clients already in production. Versioning is the contract you publish saying "this version is stable; the next version may differ."

The trick: choose where the version lives in the request — URL path, header, media type, or query parameter — and stay consistent.

The Four Strategies (and When Each Fits)

1. URL path versioning. https://api.example.com/v1/users vs /v2/users. Stripe, AWS, GitHub (currently), Twilio, most APIs you'd recognize.

  • Pro: visible in logs and curl; cache-friendly; easy to route in any framework.
  • Con: couples the URI to the version (purist REST argues each resource should have one canonical URI).
  • Verdict: default choice for most APIs. Easy to teach, easy to operate.

2. Custom header versioning. X-API-Version: 2. LinkedIn (historically), some enterprise APIs.

  • Pro: same URI across versions; clean for the REST purist.
  • Con: invisible in logs unless you log headers; CDN caching needs Vary: X-API-Version; less discoverable.
  • Verdict: works, but the operational tax is real.

3. Media-type versioning. Accept: application/vnd.example.v2+json. GitHub used this before v4; some hypermedia APIs.

  • Pro: the version is part of the representation, which is the purest REST interpretation.
  • Con: tooling (browsers, OpenAPI editors, curl users) handles this awkwardly; requires Vary; clients need to know the magic media type string.
  • Verdict: theoretically pure, practically uncommon.

4. Query parameter versioning. /users?version=2. Some quick-and-dirty APIs.

  • Pro: works without changing your framework's routing.
  • Con: breaks the URL identity (two different version queries are two different resources); harder to cache; smells lazy.
  • Verdict: avoid. URL path versioning gives you everything this does, more cleanly.

Date-Based Versioning (the Stripe Refinement)

Stripe uses path-style but with dates: /v1/charges for the path, plus a per-request header Stripe-Version: 2024-09-30.acacia that picks a snapshot of the API behavior. Clients lock in to the version they integrated against and only upgrade when they choose to. Each named version's behavior never changes.

This solves a real problem: "versions" come in many shapes (new fields, removed fields, changed defaults, renamed endpoints), and a single integer version ("v2") forces you to bundle changes. Date-based versions can be released continuously without breaking anyone.

Additive vs Breaking — The Rule That Buys You Time

You can skip versioning entirely if every change is additive:

  • Adding a new field to a response → existing clients ignore it. Safe.
  • Adding a new optional query parameter → existing clients don't send it. Safe.
  • Adding a new endpoint → existing clients never call it. Safe.
  • Adding a new status code or error case → existing clients with good family-dispatch handle it. Safe.

Versioning is for the breaking changes:

  • Removing or renaming a field. Breaks any client that reads it.
  • Changing a field's type or format. Breaks any client that parses it.
  • Tightening validation (a previously-accepted payload now returns 422). Breaks clients that relied on the looseness.
  • Changing the default value of an optional parameter. Subtle, but real.

Most production APIs add features for years before they need a v2. Default to additive; reach for versioning only when the new behavior genuinely can't co-exist with the old.

Pick a versioning strategy on day one, even if you don't use it for years. The cost of "we'll figure it out later" is that "later" arrives as a production incident — a renamed field broke 30% of mobile clients and there's no way to roll back without losing the new behavior. The cost of versioning preemptively is one route prefix and one config knob. Pay it now.

Deprecation — The Polite Goodbye

When you do need to retire an old version, signal it through the wire. Two relevant headers:

  • Deprecation: Sun, 01 Jan 2026 00:00:00 GMT — "this endpoint or version is officially deprecated as of this date." Clients can log a warning.
  • Sunset: Wed, 01 Jul 2026 00:00:00 GMT (RFC 8594) — "this endpoint will stop responding after this date." Clients have a deadline.

Pair these with a clear changelog and migration guide. The headers are the wire signal; the docs are the human signal. Both are needed.

cwkPippa's Versioning Reality

cwkPippa's API isn't versioned — the only clients are the React frontend (in the same repo, deployed in lockstep) and a handful of personal scripts. Every change is effectively v∞. cwk-site is similar — Vercel's atomic deploys mean frontend and backend always ship together. The day a third party gets a real API contract is the day /v1/ shows up. Until then, the cost of premature versioning would outweigh the protection.

Code

URL path versioning in FastAPI — clean and obvious·python
# URL path versioning in FastAPI — the most common approach
from fastapi import FastAPI, APIRouter

app = FastAPI()

# v1 router — original behavior
v1 = APIRouter(prefix='/v1')

@v1.get('/users/{uid}')
async def read_user_v1(uid: int):
    return {'id': uid, 'name': 'Pippa'}  # original shape

# v2 router — added fields, renamed `name` to `display_name`
v2 = APIRouter(prefix='/v2')

@v2.get('/users/{uid}')
async def read_user_v2(uid: int):
    return {'id': uid, 'display_name': 'Pippa', 'role': 'daughter', 'avatar_url': '...'}

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

# Both URLs exist simultaneously:
# GET /v1/users/42  → {'id': 42, 'name': 'Pippa'}
# GET /v2/users/42  → {'id': 42, 'display_name': 'Pippa', 'role': 'daughter', ...}
Header-based versioning — same URL, Vary required·python
# Header versioning — same URL, version chosen by request header
from fastapi import FastAPI, Header, HTTPException, Response

app = FastAPI()

@app.get('/users/{uid}')
async def read_user(uid: int, response: Response,
                    api_version: str = Header('1', alias='X-API-Version')):
    response.headers['Vary'] = 'X-API-Version'  # required for caching

    if api_version == '1':
        return {'id': uid, 'name': 'Pippa'}
    if api_version == '2':
        return {'id': uid, 'display_name': 'Pippa', 'role': 'daughter'}

    raise HTTPException(400, detail=f'unsupported X-API-Version: {api_version}')

# Client usage:
# curl -H 'X-API-Version: 2' https://api.example.com/users/42
Sunset/Deprecation headers — the polite retirement·python
# Deprecation + Sunset headers — RFC 8594
from datetime import datetime, timezone
from fastapi import FastAPI, Response

app = FastAPI()

# Old endpoint — still works but flagged for retirement
@app.get('/v1/users/{uid}')
async def deprecated_read(uid: int, response: Response):
    # Tell clients: 'deprecated since this date'
    response.headers['Deprecation'] = 'Sun, 01 Jan 2026 00:00:00 GMT'
    # Tell clients: 'will stop working after this date'
    response.headers['Sunset'] = 'Wed, 01 Jul 2026 00:00:00 GMT'
    response.headers['Link'] = '</v2/users/{uid}>; rel="successor-version"'
    return {'id': uid, 'name': 'Pippa'}

# Modern clients can read these headers:
# resp = httpx.get('https://api.example.com/v1/users/42')
# if 'Sunset' in resp.headers:
#     log.warning(f'this endpoint sunsets at {resp.headers["Sunset"]}; migrate by then')

External links

Exercise

Build a FastAPI app with both v1 and v2 of /users/{uid} using URL path versioning. v1 returns {id, name}; v2 returns {id, display_name, role}. Verify both work simultaneously. Then add the Deprecation and Sunset headers to the v1 endpoint (Deprecation = today + 1 day; Sunset = today + 90 days). Write a Python client that calls v1 and prints a warning if either header is present. Bonus: implement the same with header-based versioning (X-API-Version: 1 vs 2) and observe how much more annoying it is to test from curl.
Hint
FastAPI's APIRouter(prefix='/v1') is the cleanest pattern. Two routers, one app.include_router each. For the Sunset date, use (datetime.now(timezone.utc) + timedelta(days=90)).strftime('%a, %d %b %Y %H:%M:%S GMT') — that's the RFC 7231 HTTP-date format. The client just reads resp.headers.get('Sunset') and logs if present. The bonus shows the operational cost of header versioning: every curl needs -H, every log line needs the header to be useful.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.