API Versioning System Design
API Versioning System Design
Real-World Problem Context
Your payment API has been running in production for two years. 200 partner integrations depend on it. The product team wants you to change the response format: rename amount to total_amount, nest payment details inside a payment object, and drop three deprecated fields. If you just ship the change, every partner's integration breaks overnight. If you never change the API, you accumulate technical debt forever.
API versioning lets you evolve your API without breaking existing consumers. But the choice of versioning strategy (URL path, query parameter, header, content negotiation) has long-term consequences for routing, caching, documentation, and maintenance burden.
Problem Statement
APIs are contracts between your service and its consumers. Once consumers depend on a response shape, you can't change it without breaking them. But APIs must evolve — new features, better naming, security fixes, performance improvements.
The core challenge: how do you introduce breaking changes to an API while giving existing consumers time to migrate — without maintaining an unsustainable number of parallel versions?
Potential Solutions
1. URL Path Versioning
The version is part of the URL:
GET /api/v1/products/123
GET /api/v2/products/123
from fastapi import FastAPI, APIRouter
app = FastAPI()
# Version 1 routes
v1 = APIRouter(prefix="/api/v1")
@v1.get("/products/{product_id}")
async def get_product_v1(product_id: str):
product = fetch_product(product_id)
return {
"id": product.id,
"name": product.name,
"amount": product.price, # Old field name
"currency": product.currency,
"in_stock": product.in_stock,
"deprecated_field": "value", # Still returned in v1
}
# Version 2 routes
v2 = APIRouter(prefix="/api/v2")
@v2.get("/products/{product_id}")
async def get_product_v2(product_id: str):
product = fetch_product(product_id)
return {
"id": product.id,
"name": product.name,
"pricing": { # Nested object (breaking change)
"total_amount": product.price, # Renamed field
"currency": product.currency,
},
"availability": {
"in_stock": product.in_stock,
"quantity": product.quantity, # New field in v2
},
}
app.include_router(v1)
app.include_router(v2)
2. Header Versioning
Version specified in a custom header:
@app.get("/api/products/{product_id}")
async def get_product(product_id: str, request: Request):
version = request.headers.get("X-API-Version", "1")
product = fetch_product(product_id)
if version == "2":
return format_v2(product)
else:
return format_v1(product)
# Client sends:
# GET /api/products/123
# X-API-Version: 2
#
# Clean URLs but version is invisible in the URL.
# Can't share a versioned link — version is in the header.
3. Query Parameter Versioning
GET /api/products/123?version=2
@app.get("/api/products/{product_id}")
async def get_product(product_id: str, version: int = 1):
product = fetch_product(product_id)
formatters = {1: format_v1, 2: format_v2}
formatter = formatters.get(version, format_v1)
return formatter(product)
4. Content Negotiation (Accept Header)
Use the standard HTTP Accept header with a vendor media type:
@app.get("/api/products/{product_id}")
async def get_product(product_id: str, request: Request):
accept = request.headers.get("accept", "application/vnd.myapi.v1+json")
if "vnd.myapi.v2" in accept:
return format_v2(fetch_product(product_id))
else:
return format_v1(fetch_product(product_id))
# Client sends:
# Accept: application/vnd.myapi.v2+json
#
# Most RESTful approach but also the least intuitive for consumers.
# GitHub API uses this approach.
5. Additive Changes Only (No Versioning)
Avoid breaking changes entirely by only making additive changes:
# Instead of renaming or removing fields, ADD new fields:
# Original response:
{"id": "123", "amount": 10.99, "currency": "USD"}
# Evolved response (backward compatible):
{
"id": "123",
"amount": 10.99, # Keep old field (deprecated but present)
"currency": "USD",
"pricing": { # Add new structure
"total_amount": 10.99,
"currency": "USD",
}
}
# Old clients ignore "pricing" — they still read "amount".
# New clients read "pricing" — they ignore "amount".
# No version bump needed. Both clients work simultaneously.
Trade-offs & Considerations
Strategy Discoverability Caching Simplicity RESTful? Best For
─────────────────────────────────────────────────────────────────────────────────
URL path (/v1/) Very high Easy Simple Debatable Public APIs
Header Low Complex Medium Yes Internal APIs
Query param High Complex Simple No Quick iteration
Content negotiation Medium Complex Complex Most GitHub-style
No versioning N/A Easy Simplest Yes Well-designed APIs
The maintenance cost of versions:
1 version: 1 set of handlers, tests, docs.
2 versions: 2 sets. Some shared logic, some diverged.
3 versions: Combinatorial complexity. Bug fix in shared logic
must be tested against all 3 versions.
After 5 versions, you're maintaining 5 parallel APIs.
Each version needs tests, documentation, monitoring.
Rule of thumb: support at most 2-3 active versions.
Deprecate aggressively with clear timelines.
Best Practices
-
Prefer additive changes over new versions — adding a field is backward-compatible. Renaming, removing, or restructuring fields requires a new version. Design APIs with evolution in mind from day one.
-
Use URL path versioning for public APIs — it's the most visible and intuitive approach. Stripe, Twilio, and most public APIs use it. Easy to test, share, and cache.
-
Support at most N-1 and N — maintain only the current and previous version. Give consumers 6-12 months to migrate. Then sunset the old version.
-
Deprecation headers on old versions — return
Sunset: Sat, 01 Jan 2026 00:00:00 GMTandDeprecation: trueheaders so clients can detect and plan migration. -
Share business logic, version only the serialization — the handler for
/v1/productsand/v2/productsshould call the same service layer. Only the response formatting differs. -
Version the entire API, not individual endpoints —
/api/v2/means the entire v2 API. Don't have/api/v1/productsand/api/v2/orders— that's confusing.
Step-by-Step Approach
Step 1: Determine if you actually need a new version
├── Adding a field? → No version needed (additive change)
├── Adding an optional parameter? → No version needed
├── Renaming a field? → Version needed (or keep both)
├── Removing a field? → Version needed
└── Changing a field's type? → Version needed
Step 2: Design the new version
├── Define the new response shape
├── Share business logic with the old version
└── Only serialize differently in the response layer
Step 3: Implement side-by-side
├── Add new versioned routes
├── Old routes continue to work identically
└── Test both versions with full test suites
Step 4: Communicate the migration
├── Add deprecation headers to v1 responses
├── Document migration guide (field mapping, new features)
├── Announce timeline: "v1 sunset in 12 months"
└── Notify high-usage consumers directly
Step 5: Monitor adoption
├── Track request volume per version
├── Identify consumers still on v1
├── Reach out to slow migrators
└── Extend deadline only if significant usage remains
Step 6: Sunset the old version
├── Return 410 Gone (not 404) after sunset date
├── Include migration URL in error response
└── Remove v1 code after 30-day grace period
Conclusion
The best API versioning strategy is to avoid versioning as long as possible by making only additive, backward-compatible changes. When breaking changes are unavoidable, URL path versioning (/api/v1/, /api/v2/) is the most practical choice for public APIs — it's visible, cacheable, and simple. Keep versions minimal (max 2-3 active), share business logic between versions (only the serialization layer should differ), and deprecate aggressively with clear timelines and sunset headers. The real cost of API versioning isn't the routing — it's maintaining parallel test suites, documentation, and monitoring for every active version.
What did you think?