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
· Updated · 15 min read
- APIs
- Architecture
- FastAPI
- Go
- Backend Design
Table of Contents:
- Why API Versioning Matters
- Versioning Strategy Comparison
- Choosing an API Versioning Strategy
- Strategy 1: URL Versioning (Most Common)
- Strategy 2: Header Versioning
- Strategy 3: Content Negotiation (Media Type)
- Strategy 4: Query Parameter Versioning
- Backward Compatibility Patterns
- Deprecation and Sunsetting
- Version Migration for Clients
- Testing Multi-Version APIs
- Production Examples (Stripe, GitHub, AWS)
- Versioning at the Gateway and in Contracts
- Frequently Asked Questions
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
| Strategy | Pros | Cons | Best For |
|---|---|---|---|
URL versioning (/v1/users) | ✅ Simple, explicit, easy routing<br>✅ Works with all clients<br>✅ Browser-testable | ❌ URL proliferation<br>❌ Resource duplication | Public 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 complications | Internal APIs, hypermedia APIs |
Query parameter (?version=2) | ✅ Easy for simple cases | ❌ Pollutes query namespace<br>❌ Not RESTful | Temporary bridges, A/B testing |
Content negotiation (Accept: application/json; version=2) | ✅ RESTful<br>✅ Flexible | ❌ Complex routing<br>❌ Poor tooling support | Mature APIs with strict REST adherence |
Adoption by Major APIs (2026)
| Company | Strategy | Example |
|---|---|---|
| Stripe | URL versioning | https://api.stripe.com/v1/charges |
| GitHub | URL + Header fallback | https://api.github.com/repos + X-GitHub-Api-Version: 2022-11-28 |
| Twilio | URL versioning | https://api.twilio.com/2010-04-01/Accounts |
| AWS | Query parameter (S3) | ?x-id=ListObjectsV2 |
| Salesforce | URL versioning | https://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)
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"}
# 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)
# 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
// 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)
}
// 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)
}
// 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
# 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
# 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)
# 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:
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: Acceptheader
Strategy 3: Content Negotiation (Media Type)
Encode version in media type: Accept: application/json; version=2.
Implementation
# 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:
curl -H "Accept: application/json; version=2" \
https://api.example.com/users/123
GitHub's approach (date-based versioning):
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
# 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:
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
# 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
# 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
# V1: {"id": 1, "name": "Alice", "age": 30}
# V2: {"id": 1, "name": "Alice"} # Removed 'age'
❌ Rename fields
# V1: {"id": 1, "name": "Alice"}
# V2: {"id": 1, "full_name": "Alice"} # Renamed 'name' to 'full_name'
❌ Change field types
# V1: {"id": 1, "created_at": "2026-09-11T10:00:00Z"} # ISO string
# V2: {"id": 1, "created_at": 1726052400} # Unix timestamp
❌ Add required fields
# V1 POST /users: {"name": "Alice"}
# V2 POST /users: {"name": "Alice", "email": "required@example.com"} # 'email' now required
❌ Change endpoint URLs or HTTP methods
# V1: POST /users/create # V2: POST /users # Changed URL
Adapter Pattern (Share Business Logic)
# 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.
# 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
# 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
# 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
# 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
# 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
# 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:
# 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):
# 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:
# 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?
- Announce deprecation (email, changelog, API response headers)
- Grace period (6–12 months where v1 still works but returns warnings)
- Migration guide (docs with side-by-side examples)
- Sunset date (v1 returns 410 Gone after date)
- 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
Resources:
Keep reading
Related articles
API Design Mistakes That Kill Performance: Fixes That Work
Eight API design mistakes that kill performance — N+1 calls, over-fetching, offset pagination, sync side effects — with Go, Python, and TypeScript fixes.
Read post
gRPC vs REST vs GraphQL: How to Choose the Right API
Learn grpc vs rest vs graphql through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Batch API for LLM Workloads: 50% Cost Savings on
Learn batch api for llm workloads through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Async LLM API Calls with FastAPI and Server-Sent Events
Build async LLM API calls with FastAPI: SSE streaming, semaphore concurrency control, retries, circuit breakers and deployment patterns for production.
Read post
