HinterBuild logoHinterBuild
Backend Systems · 15 min read

API Versioning Strategies That Work in Production

API versioning strategies compared — URL, header, media type, and query versioning — with FastAPI and Go code, deprecation headers, and migration plans.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 15 min read

  • APIs
  • Architecture
  • FastAPI
  • Go
  • Backend Design

Table of Contents:

Why API Versioning Matters

Short answer: API versioning allows breaking changes without disrupting existing clients — critical for backend systems with mobile apps, third-party integrations, and SLA guarantees where forced upgrades cause outages. The API versioning strategies that survive in production are the boring ones: a version in the URL, a shared core behind thin adapters, and a deprecation process clients can plan around.

If you searched "API versioning strategies", you're adding breaking changes to a production API, onboarding clients with different feature needs, or replacing a monolithic API with microservices.

Key Takeaways:

  • Breaking changes require versioning (removing fields, changing types, new required parameters)
  • URL versioning (/v1/, /v2/) is simplest and most widely adopted (Stripe, GitHub, Twilio)
  • Header versioning keeps URLs clean but complicates caching and browser testing
  • Never support more than 3 versions simultaneously (maintenance cost explodes)
  • Deprecation timeline: announce → grace period (6–12 months) → sunset → delete code
  • Semantic versioning for API changes: MAJOR.MINOR.PATCH (e.g., v2.1.0)

This guide covers API versioning strategies that work with production code (Python FastAPI, Go), migration patterns, and the decision framework we use at HinterBuild for backend API design.


Versioning Strategy Comparison

Decision Matrix

StrategyProsConsBest For
URL versioning (/v1/users)✅ Simple, explicit, easy routing<br>✅ Works with all clients<br>✅ Browser-testable❌ URL proliferation<br>❌ Resource duplicationPublic REST APIs (Stripe, GitHub)
Header versioning (Accept: application/vnd.api.v2+json)✅ Clean URLs<br>✅ Supports content negotiation❌ Hard to test (curl requires headers)<br>❌ CDN caching complicationsInternal APIs, hypermedia APIs
Query parameter (?version=2)✅ Easy for simple cases❌ Pollutes query namespace<br>❌ Not RESTfulTemporary bridges, A/B testing
Content negotiation (Accept: application/json; version=2)✅ RESTful<br>✅ Flexible❌ Complex routing<br>❌ Poor tooling supportMature APIs with strict REST adherence

Adoption by Major APIs (2026)

CompanyStrategyExample
StripeURL versioninghttps://api.stripe.com/v1/charges
GitHubURL + Header fallbackhttps://api.github.com/repos + X-GitHub-Api-Version: 2022-11-28
TwilioURL versioninghttps://api.twilio.com/2010-04-01/Accounts
AWSQuery parameter (S3)?x-id=ListObjectsV2
SalesforceURL versioninghttps://instance.salesforce.com/services/data/v58.0/

Recommendation: URL versioning for public APIs; header versioning for internal microservices.


Choosing an API Versioning Strategy

The comparison table tells you what each strategy costs. The decision itself depends on four questions about your clients, not about REST purity.

Who are the clients, and can you reach them? If consumers are third parties or mobile apps in app stores, you need a version that is visible in logs, in support tickets, and in a browser address bar. That pushes toward URL versioning. If every caller is an internal service you deploy yourself, header versioning costs less and you can migrate callers in the same release train.

How is the API cached? CDNs and reverse proxies key on the URL by default. A version in the path caches correctly with zero configuration. A version in a header requires a Vary response header, and per RFC 9110 caches must then store a separate variant per header value — which works, but is easy to misconfigure and hard to debug when a stale v1 body is served to a v2 caller.

How often do you break things? Stripe's model — a stable /v1/ path plus a date-stamped Stripe-Version header for behavioural changes — exists because they ship small breaking changes frequently and want clients pinned to the behaviour they tested against. If you expect one major break every few years, a plain /v2/ is enough and the date-based machinery is over-engineering.

Which layer owns routing? If an API gateway or service mesh routes traffic, path prefixes are the routing primitive every gateway understands. Header-based routing is supported (Kong, Envoy, and NGINX all can do it) but it moves versioning logic out of application code and into infrastructure config, which spreads the knowledge of "what versions exist" across two systems.

A practical rule: choose URL versioning unless you can name the specific reason header versioning is better for your clients. The teams that regret their choice are almost always the ones that picked header or media-type versioning for elegance and then spent months explaining to partners why curl in a README needs an Accept header.


Strategy 1: URL Versioning (Most Common)

Put version in the URL path: /v1/resource, /v2/resource.

Implementation (FastAPI)

python
from fastapi import FastAPI
from api.v1 import router as v1_router
from api.v2 import router as v2_router

app = FastAPI(title="My API")

# Mount version-specific routers
app.include_router(v1_router, prefix="/v1", tags=["v1"])
app.include_router(v2_router, prefix="/v2", tags=["v2"])

# Default to latest version
app.include_router(v2_router, prefix="/api", tags=["latest"])

# Redirect root to docs
@app.get("/")
async def root():
    return {"message": "See /docs for API documentation"}
python
# api/v1/users.py — Version 1 implementation
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class UserV1(BaseModel):
    id: int
    name: str  # Single 'name' field

@router.get("/users/{user_id}", response_model=UserV1)
async def get_user_v1(user_id: int):
    # Fetch from database
    user = await db.fetch_user(user_id)
    return UserV1(id=user.id, name=user.full_name)
python
# api/v2/users.py — Version 2 with breaking change (split name)
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class UserV2(BaseModel):
    id: int
    first_name: str  # BREAKING: Split 'name' into 'first_name' + 'last_name'
    last_name: str
    email: str  # NEW: Added email field (non-breaking)

@router.get("/users/{user_id}", response_model=UserV2)
async def get_user_v2(user_id: int):
    user = await db.fetch_user(user_id)
    
    # Split full_name for v1 compatibility
    parts = user.full_name.split(' ', 1)
    first_name = parts[0]
    last_name = parts[1] if len(parts) > 1 else ""
    
    return UserV2(
        id=user.id,
        first_name=first_name,
        last_name=last_name,
        email=user.email
    )

Go Implementation

go
// api/main.go — URL versioning in Go
package main

import (
    "net/http"
    "github.com/gorilla/mux"
    "myapi/handlers/v1"
    "myapi/handlers/v2"
)

func main() {
    r := mux.NewRouter()
    
    // V1 routes
    v1Router := r.PathPrefix("/v1").Subrouter()
    v1Router.HandleFunc("/users/{id}", v1.GetUser).Methods("GET")
    v1Router.HandleFunc("/users", v1.CreateUser).Methods("POST")
    
    // V2 routes
    v2Router := r.PathPrefix("/v2").Subrouter()
    v2Router.HandleFunc("/users/{id}", v2.GetUser).Methods("GET")
    v2Router.HandleFunc("/users", v2.CreateUser).Methods("POST")
    
    // Default to v2
    r.HandleFunc("/users/{id}", v2.GetUser).Methods("GET")
    
    http.ListenAndServe(":8000", r)
}
go
// handlers/v1/users.go
package v1

type UserV1 struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func GetUser(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    userID := vars["id"]
    
    user := fetchUserFromDB(userID)
    
    response := UserV1{
        ID:   user.ID,
        Name: user.FullName,
    }
    
    json.NewEncoder(w).Encode(response)
}
go
// handlers/v2/users.go
package v2

type UserV2 struct {
    ID        int    `json:"id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Email     string `json:"email"`
}

func GetUser(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    userID := vars["id"]
    
    user := fetchUserFromDB(userID)
    
    // Split full name for v2
    parts := strings.SplitN(user.FullName, " ", 2)
    firstName := parts[0]
    lastName := ""
    if len(parts) > 1 {
        lastName = parts[1]
    }
    
    response := UserV2{
        ID:        user.ID,
        FirstName: firstName,
        LastName:  lastName,
        Email:     user.Email,
    }
    
    json.NewEncoder(w).Encode(response)
}

Pros and Cons

Pros:

  • Explicit — version is obvious in every request
  • Easy routing — simple prefix matching
  • Works everywhere — browsers, curl, Postman
  • Per-version caching — CDN can cache separately

Cons:

  • URL pollution/v1/users, /v2/users, /v3/users
  • Code duplication — need separate handlers (mitigate with shared business logic)

For API design patterns, see our API engineering guide.


Strategy 2: Header Versioning

Version via HTTP header: Accept: application/vnd.myapi.v2+json or X-API-Version: 2.

Implementation

python
# api/versioned.py — Header-based versioning
from fastapi import FastAPI, Header, HTTPException
from typing import Optional

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(
    user_id: int,
    x_api_version: Optional[str] = Header(None, alias="X-API-Version")
):
    """Route to correct version based on header"""
    
    version = x_api_version or "2"  # Default to latest
    
    if version == "1":
        return get_user_v1(user_id)
    elif version == "2":
        return get_user_v2(user_id)
    else:
        raise HTTPException(status_code=400, detail=f"Unsupported version: {version}")

def get_user_v1(user_id: int):
    user = fetch_user(user_id)
    return {"id": user.id, "name": user.full_name}

def get_user_v2(user_id: int):
    user = fetch_user(user_id)
    first, last = split_name(user.full_name)
    return {
        "id": user.id,
        "first_name": first,
        "last_name": last,
        "email": user.email
    }

Client Usage

bash
# V1 request
curl -H "X-API-Version: 1" https://api.example.com/users/123

# V2 request
curl -H "X-API-Version: 2" https://api.example.com/users/123

# Default (latest)
curl https://api.example.com/users/123

Content Negotiation (Accept Header)

python
# api/content_negotiation.py — RFC 7231 content negotiation
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(user_id: int, request: Request):
    """Parse Accept header for version"""
    
    accept = request.headers.get("Accept", "application/json")
    
    # Parse: "application/vnd.myapi.v2+json"
    if "vnd.myapi.v1+json" in accept:
        return get_user_v1(user_id)
    elif "vnd.myapi.v2+json" in accept or "application/json" in accept:
        return get_user_v2(user_id)
    else:
        raise HTTPException(status_code=406, detail="Not Acceptable")

Client request:

bash
curl -H "Accept: application/vnd.myapi.v2+json" \
    https://api.example.com/users/123

Pros and Cons

Pros:

  • Clean URLs — no version in path
  • RESTful — follows HTTP semantics
  • Flexible — can vary response format AND version

Cons:

  • Complex routing — need custom middleware
  • Hard to test — requires header manipulation (not browser-friendly)
  • CDN caching issues — need Vary: Accept header

Strategy 3: Content Negotiation (Media Type)

Encode version in media type: Accept: application/json; version=2.

Implementation

python
# api/media_type_versioning.py — Media type versioning
from fastapi import FastAPI, Request, Response
import re

app = FastAPI()

def extract_version_from_accept(accept_header: str) -> str:
    """Extract version from Accept header"""
    # Parse: "application/json; version=2"
    match = re.search(r'version=(\d+)', accept_header)
    if match:
        return match.group(1)
    return "2"  # Default to latest

@app.get("/users/{user_id}")
async def get_user(user_id: int, request: Request, response: Response):
    accept = request.headers.get("Accept", "application/json")
    version = extract_version_from_accept(accept)
    
    # Set Content-Type with version
    response.headers["Content-Type"] = f"application/json; version={version}"
    
    if version == "1":
        return get_user_v1(user_id)
    elif version == "2":
        return get_user_v2(user_id)
    else:
        return {"error": f"Unsupported version: {version}"}, 400

Client request:

bash
curl -H "Accept: application/json; version=2" \
    https://api.example.com/users/123

GitHub's approach (date-based versioning):

bash
curl -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/repos/owner/repo

Strategy 4: Query Parameter Versioning

Version via query string: /users?version=2.

Implementation

python
# api/query_versioning.py — Query parameter versioning
from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(user_id: int, version: int = Query(2, ge=1, le=2)):
    """Version via query parameter"""
    
    if version == 1:
        return get_user_v1(user_id)
    elif version == 2:
        return get_user_v2(user_id)

Client request:

bash
curl "https://api.example.com/users/123?version=1"
curl "https://api.example.com/users/123?version=2"
curl "https://api.example.com/users/123"  # Default v2

When to Use

Good for:

  • ✅ A/B testing (split traffic by version)
  • ✅ Gradual rollout (route % of traffic to v2)
  • ✅ Temporary versioning (bridge to full URL versioning)

Bad for:

  • ❌ Long-term versioning strategy (query params pollute namespace)
  • ❌ REST APIs (violates resource addressing principles)

Backward Compatibility Patterns

Not all changes require a new version. Follow backward compatibility rules.

Safe (Non-Breaking) Changes

Add optional fields — existing clients ignore new fields

python
# V1 response
{"id": 1, "name": "Alice"}

# V1.1 response (backward compatible)
{"id": 1, "name": "Alice", "email": "alice@example.com"}  # New field

Add new endpoints/v1/new-resource

Add new query parameters (optional)?filter=active

Deprecate fields (but keep returning them) — with warning

python
# V1.2 response (deprecated field marked)
{
    "id": 1,
    "name": "Alice",  # Deprecated, use first_name + last_name
    "first_name": "Alice",
    "last_name": "Smith"
}

Breaking Changes (Require New Version)

Remove fields

python
# V1: {"id": 1, "name": "Alice", "age": 30}
# V2: {"id": 1, "name": "Alice"}  # Removed 'age'

Rename fields

python
# V1: {"id": 1, "name": "Alice"}
# V2: {"id": 1, "full_name": "Alice"}  # Renamed 'name' to 'full_name'

Change field types

python
# V1: {"id": 1, "created_at": "2026-09-11T10:00:00Z"}  # ISO string
# V2: {"id": 1, "created_at": 1726052400}  # Unix timestamp

Add required fields

python
# V1 POST /users: {"name": "Alice"}
# V2 POST /users: {"name": "Alice", "email": "required@example.com"}  # 'email' now required

Change endpoint URLs or HTTP methods

python
# V1: POST /users/create
# V2: POST /users  # Changed URL

Adapter Pattern (Share Business Logic)

python
# business_logic/users.py — Version-agnostic core
from dataclasses import dataclass

@dataclass
class User:
    """Internal user representation"""
    id: int
    full_name: str
    email: str

async def get_user_core(user_id: int) -> User:
    """Shared business logic (version-agnostic)"""
    row = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
    return User(id=row['id'], full_name=row['full_name'], email=row['email'])

# api/v1/users.py — V1 adapter
from business_logic.users import get_user_core

@router.get("/users/{user_id}")
async def get_user_v1(user_id: int):
    user = await get_user_core(user_id)
    return {"id": user.id, "name": user.full_name}  # V1 format

# api/v2/users.py — V2 adapter
from business_logic.users import get_user_core

@router.get("/users/{user_id}")
async def get_user_v2(user_id: int):
    user = await get_user_core(user_id)
    first, last = split_name(user.full_name)
    return {
        "id": user.id,
        "first_name": first,
        "last_name": last,
        "email": user.email
    }  # V2 format

This pattern:

  • ✅ Avoids business logic duplication across versions
  • ✅ Keeps version adapters thin (presentation layer only)
  • ✅ Makes testing easier (test core logic once)

Deprecation and Sunsetting

Never delete API versions without warning. Follow a deprecation process.

Deprecation Timeline

Month 0: Announce deprecation (v1 deprecated, use v2)
Month 1-6: Grace period (both versions work, warnings sent)
Month 6: Sunset date announced (v1 stops working on date X)
Month 12: V1 deleted (HTTP 410 Gone)

Deprecation Headers

The Sunset header is standardised in RFC 8594: it carries an HTTP-date after which the resource is expected to become unavailable. Pair it with a Link header using rel="successor-version" so tooling can discover the replacement automatically, and a Deprecation header so client libraries can log a warning the first time they see it. Headers matter more than emails: the developer who integrated with your API two years ago may have left the company, but the headers reach whoever is running the code today.

python
# api/v1/deprecated.py — Deprecation warnings
from fastapi import FastAPI, Response
from datetime import datetime

app = FastAPI()

@app.get("/v1/users/{user_id}")
async def get_user_v1_deprecated(user_id: int, response: Response):
    """V1 endpoint with deprecation warning"""
    
    # Add deprecation headers
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Sat, 31 Dec 2026 23:59:59 GMT"
    response.headers["Link"] = '</v2/users>; rel="successor-version"'
    
    # Optional: Add warning to response body
    user = await get_user_core(user_id)
    return {
        "id": user.id,
        "name": user.full_name,
        "_deprecation": {
            "message": "v1 is deprecated. Migrate to v2 by 2026-12-31.",
            "sunset_date": "2026-12-31T23:59:59Z",
            "migration_guide": "https://docs.example.com/migration/v1-to-v2"
        }
    }

HTTP 410 Gone After Sunset

python
# api/v1/sunset.py — Return 410 after sunset date
from datetime import datetime

SUNSET_DATE = datetime(2026, 12, 31, 23, 59, 59)

@app.get("/v1/users/{user_id}")
async def get_user_v1_gone(user_id: int):
    if datetime.utcnow() > SUNSET_DATE:
        raise HTTPException(
            status_code=410,
            detail={
                "error": "gone",
                "message": "v1 API was sunset on 2026-12-31. Use v2.",
                "migration_guide": "https://docs.example.com/migration/v1-to-v2"
            }
        )
    
    # Otherwise, serve deprecated endpoint with warnings
    return get_user_v1_deprecated(user_id)

Client Migration Communication

Email to API consumers 6 months before sunset:

Subject: Action Required: Migrate to API v2 by Dec 31, 2026

Hi [Customer],

On December 31, 2026, we will sunset API v1. After this date, v1 endpoints will return HTTP 410 Gone.

What you need to do:
1. Migrate to API v2 (see migration guide: https://docs.example.com/migration)
2. Test your integration against v2 in our sandbox
3. Deploy before sunset date

Key changes in v2:
- User 'name' field split into 'first_name' and 'last_name'
- Email field added (non-breaking)
- Pagination now uses cursor-based (not offset)

Need help? Contact support@example.com or schedule a call: https://calendly.com/...

Thanks,
API Team

For zero-downtime API deployment, see our deployment guide.


Version Migration for Clients

Client-Side Version Pinning

python
# client/api_client.py — Pin API version in client SDK
import requests

class APIClient:
    def __init__(self, api_key: str, version: str = "2"):
        self.api_key = api_key
        self.version = version
        self.base_url = f"https://api.example.com/v{version}"
    
    def get_user(self, user_id: int):
        response = requests.get(
            f"{self.base_url}/users/{user_id}",
            headers={"Authorization": f"Bearer {self.api_key}"}
        )
        response.raise_for_status()
        return response.json()

# Usage
client_v1 = APIClient(api_key="abc123", version="1")
user_v1 = client_v1.get_user(123)  # Returns: {"id": 123, "name": "Alice"}

client_v2 = APIClient(api_key="abc123", version="2")
user_v2 = client_v2.get_user(123)  # Returns: {"id": 123, "first_name": "Alice", "last_name": "Smith"}

Feature Flags for Gradual Migration

python
# client/feature_flags.py — Gradual v2 migration
import requests

class APIClient:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.use_v2 = get_feature_flag("api_v2_enabled")  # From config service
    
    def get_user(self, user_id: int):
        version = "2" if self.use_v2 else "1"
        url = f"https://api.example.com/v{version}/users/{user_id}"
        
        response = requests.get(url, headers={"Authorization": f"Bearer {self.api_key}"})
        
        if self.use_v2:
            return self._parse_v2_response(response.json())
        else:
            return self._parse_v1_response(response.json())
    
    def _parse_v1_response(self, data):
        return {"id": data["id"], "name": data["name"]}
    
    def _parse_v2_response(self, data):
        # Adapt v2 format to match v1 internal representation
        full_name = f"{data['first_name']} {data['last_name']}"
        return {"id": data["id"], "name": full_name}

Enable api_v2_enabled feature flag for 10% → 50% → 100% of traffic over weeks.


Testing Multi-Version APIs

Contract Testing with Pact

python
# tests/test_api_contract.py — Ensure v1 and v2 contracts don't break
import pytest
from pact import Consumer, Provider

@pytest.fixture
def pact():
    return Consumer("mobile-app").has_pact_with(Provider("api-service"))

def test_v1_get_user(pact):
    """Test v1 contract"""
    pact.given("User 123 exists") \
        .upon_receiving("a request for user 123") \
        .with_request("GET", "/v1/users/123") \
        .will_respond_with(200, body={"id": 123, "name": "Alice"})
    
    with pact:
        client = APIClient(version="1")
        user = client.get_user(123)
        assert user["name"] == "Alice"

def test_v2_get_user(pact):
    """Test v2 contract"""
    pact.given("User 123 exists") \
        .upon_receiving("a request for user 123") \
        .with_request("GET", "/v2/users/123") \
        .will_respond_with(200, body={
            "id": 123,
            "first_name": "Alice",
            "last_name": "Smith",
            "email": "alice@example.com"
        })
    
    with pact:
        client = APIClient(version="2")
        user = client.get_user(123)
        assert user["first_name"] == "Alice"

Backward Compatibility Testing

python
# tests/test_backward_compat.py — Ensure v2 doesn't break v1 clients
import pytest

def test_v1_still_works_after_v2_deployment():
    """Ensure v1 endpoint returns expected format"""
    response = requests.get("https://api.example.com/v1/users/123")
    data = response.json()
    
    assert "id" in data
    assert "name" in data  # V1 format
    assert "first_name" not in data  # V2 field shouldn't leak into v1

def test_v2_new_fields_present():
    """Ensure v2 includes new fields"""
    response = requests.get("https://api.example.com/v2/users/123")
    data = response.json()
    
    assert "id" in data
    assert "first_name" in data
    assert "last_name" in data
    assert "email" in data

For testing strategies, see our system design guide.


Production Examples (Stripe, GitHub, AWS)

Stripe API Versioning

URL versioning + date-based releases:

bash
# Stripe uses /v1/ in URL + X-Stripe-Version header for sub-versions
curl https://api.stripe.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Stripe-Version: 2023-10-16"

Versioning model (see Stripe's API versioning docs):

  • Major changes: New /v2/ URL (rare)
  • Minor changes: Date-based versions (2023-10-16, 2024-03-01)
  • Clients pin to specific date, Stripe backports fixes

The interesting engineering detail is how Stripe implements this internally: each date version is a small transformation layer, and a request pinned to an old date passes its response through every transformation between the current version and the pinned one. That is the adapter pattern from earlier, applied as a chain. The cost is that every transformation must be reversible and independent, which constrains how you design changes — but it is what lets them keep years-old integrations working without maintaining parallel codebases.

GitHub API Versioning

URL versioning + header override (GitHub REST API versions):

bash
# Default version in URL
curl https://api.github.com/repos/owner/repo

# Override with header for preview features
curl https://api.github.com/repos/owner/repo \
  -H "X-GitHub-Api-Version: 2022-11-28"

AWS S3 API Versioning

Action-based versioning in query params:

bash
# Old ListObjects (v1)
aws s3api list-objects --bucket my-bucket

# New ListObjectsV2
aws s3api list-objects-v2 --bucket my-bucket

AWS keeps both versions indefinitely (backward compatibility forever). That policy is only affordable because the old operation is frozen — no new features, no behavioural changes — so its maintenance cost approaches zero. If you promise "forever", freeze the old version completely.


Versioning at the Gateway and in Contracts

Two practices reduce the operational cost of running multiple versions at once.

Route versions at the edge, not in every handler

Once you have more than a handful of endpoints, per-handler if version == "1" branches become unmaintainable. Put version resolution in one place — an API gateway, a router prefix, or a single middleware — and let handlers receive an already-resolved version. With URL versioning this is free: the router does it. With header versioning, write one middleware that reads the header, validates it against the supported set, rejects unknown values with 400, and stores the result on the request context. Handlers then never parse headers themselves.

This also gives you a single place to emit metrics: requests per version per endpoint is the number that tells you when a version is safe to sunset. Without it, teams guess, and the guess is usually wrong in the direction of "nobody uses v1 anymore" right up until the 410s start a support incident. Instrumenting this is a small observability investment with an outsized payoff.

Treat the version as a contract, and test the contract

A version is a promise about response shape. Consumer-driven contract tests, such as those in the Pact framework, let each client team record the exact fields they depend on. The provider then runs those contracts in CI, so a change that removes a field a v1 mobile client still reads fails the build before it reaches production. This is far cheaper than discovering the break from crash reports. It also answers the "can we drop this field?" question with data: if no consumer contract references it, removing it is a non-breaking change in practice even if it is breaking in theory.

Combine contract tests with the feature-flag rollout described above and you can move a client population from v1 to v2 gradually, with a kill switch, and with proof that both versions still satisfy every registered consumer.


Frequently Asked Questions

Should I use URL versioning or header versioning?

URL versioning for public-facing REST APIs (simplicity, discoverability). Header versioning for internal microservices where clean URLs matter and all clients are under your control.

Stripe, Twilio, GitHub use URL versioning for a reason — it's the most pragmatic approach.

How many API versions should I support simultaneously?

Maximum 2 active versions (current + previous). Supporting 3+ versions is maintenance hell.

Deprecation timeline: Announce v3 → 6-month grace period → sunset v1 → only v2 and v3 active.

What is semantic versioning for APIs?

MAJOR.MINOR.PATCH, following the rules at semver.org:

  • MAJOR (v1 → v2): Breaking changes (remove field, change type)
  • MINOR (v2.0 → v2.1): Backward-compatible additions (new field, new endpoint)
  • PATCH (v2.1.0 → v2.1.1): Bug fixes (no API changes)

Communicate MAJOR changes with new URL (/v2/); MINOR/PATCH in docs only.

Do I need versioning if I control all clients?

Yes, if clients are mobile apps. You can't force mobile users to update immediately. Android/iOS apps in the wild will call your API for months after release.

Maybe not if all clients are internal services you deploy simultaneously (but versioning still helps with gradual rollouts).

How do I migrate clients from v1 to v2?

  1. Announce deprecation (email, changelog, API response headers)
  2. Grace period (6–12 months where v1 still works but returns warnings)
  3. Migration guide (docs with side-by-side examples)
  4. Sunset date (v1 returns 410 Gone after date)
  5. Delete v1 code (after sunset + 30 days)

Should I version my database schema too?

No. Database schema is internal implementation. API versions map to the same underlying database — version adapters transform data during serialization.

For database migrations, see our schema migration guide.

Is adding a field to a JSON response a breaking change?

No — adding an optional field is backward compatible for any reasonably written client. Clients that fail on unknown fields are using strict deserialisation, which is their choice, not a contract you made. The exception is if you documented the response as a fixed schema with additionalProperties: false; then adding a field breaks validation and needs a version bump or a schema update first.

How do I version a GraphQL or gRPC API?

GraphQL is designed to evolve without versions: you add fields and mark old ones @deprecated, and clients only request the fields they need. gRPC relies on Protocol Buffers field numbering — never reuse or renumber a field, and old clients keep working. Both approaches are additive-only; a truly incompatible change still requires a new service or package name. See our gRPC vs REST vs GraphQL comparison for how these protocols differ in practice.


Conclusion

API versioning is mandatory for production systems where breaking changes can't wait for coordinated client updates. URL versioning (/v1/, /v2/) remains the most practical approach — simple, explicit, and works everywhere.

Key Recommendations:

  • URL versioning for public APIs (Stripe model)
  • Semantic versioning for change communication (MAJOR.MINOR.PATCH)
  • Max 2 active versions (current + previous, deprecate aggressively)
  • 6–12 month grace period before sunsetting old versions
  • Shared business logic across versions (only adapters differ)

Build maintainable versioned APIs with our backend API engineering services, or contact us to review your deprecation timeline before the next breaking change ships.

Free consultation

Book a free consultation call on API versioning & backward compatibility

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading