How to Implement API Versioning: URL vs Header Strategies

Prerequisites

  • A REST API (any framework)
  • Understanding of HTTP methods and status codes

Why Version Your API

APIs evolve. Fields get renamed, endpoints change behaviour, response formats shift. Without versioning, any change breaks every client. Versioning lets you release breaking changes without disrupting existing consumers.

The goal: clients opt into new versions on their schedule, not yours.

Strategy 1: URL Path Versioning

The most common and visible approach:

GET /api/v1/users
GET /api/v2/users

Implementation (FastAPI example):

# v1 router
from fastapi import APIRouter

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

@v1.get("/users")
def get_users_v1():
    return {"version": "v1", "users": [{"name": "John"}]}

# v2 router
v2 = APIRouter(prefix="/api/v2")

@v2.get("/users")
def get_users_v2():
    return {"version": "v2", "users": [{"first_name": "John", "last_name": "Doe"}]}

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

Pros: Simple, visible in logs, easy to route via reverse proxy Cons: URL clutter, requires duplicate route registrations, clients must change URLs

Strategy 2: Header-Based Versioning

Version is specified in a custom header:

GET /api/users
Accept-Version: v2

Or using content negotiation:

GET /api/users
Accept: application/vnd.myapp.v2+json

Implementation:

from fastapi import Header, HTTPException

@app.get("/users")
def get_users(api_version: str = Header(default="v1")):
    if api_version == "v1":
        return {"version": "v1", "name": "John"}
    elif api_version == "v2":
        return {"version": "v2", "first_name": "John", "last_name": "Doe"}
    raise HTTPException(400, f"Unknown version: {api_version}")

Pros: Clean URLs, no route duplication Cons: Harder to test in browser, caching proxies may not vary by custom header

Strategy 3: Query Parameter Versioning

GET /api/users?version=2

Pros: Easiest to test in browser Cons: Pollutes query parameters, easy for clients to forget, doesn’t feel RESTful

Deprecation Strategy

Never remove old versions without warning. The lifecycle:

  1. Announce — email, changelog, API status page
  2. Warn in response headers — add deprecation notices
Deprecation: true
Sunset: Sat, 01 Aug 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
  1. Warn in response body — include a deprecation_warning field
  2. Monitor usage — track how many requests still hit old versions
  3. Remove — only when usage drops to near-zero

Implementation:

@app.middleware("http")
async def deprecation_header(request, call_next):
    response = await call_next(request)
    if "/v1/" in request.url.path:
        response.headers["Deprecation"] = "true"
        response.headers["Sunset"] = "Sat, 01 Aug 2026 00:00:00 GMT"
    return response

Migration Strategy

Help clients migrate:

  1. Provide a migration guide documenting every breaking change
  2. Offer a compatibility mode — v2 endpoint with v1 response shape via query param ?compat=v1
  3. Run both versions concurrently during the deprecation window
  4. Set up automated tests against each active version

Which Strategy to Choose?

StrategyBest for
URL pathPublic APIs, third-party integrations, simple caching
HeaderInternal APIs, microservices, clean URLs
Query paramQuick experiments, internal tools

Recommendation: Use URL path versioning for public APIs. It’s the most transparent and debug-friendly. Reserve major version bumps (v1 → v2) for breaking changes. Don’t version for additive changes — just add fields.

FAQ

Q: How many versions should I maintain? At most 2 active versions (current + previous). Sunset old ones aggressively.

Q: Do I need to version if I only add fields? No. Adding optional fields is backward-compatible. Only version when you remove, rename, or change the type/semantics of existing fields.

Q: Can I skip v1 and start at v2? Don’t. It confuses everyone. Start at v1.

References


Advertisement