API Versioning Strategies: URL Path, Header, Query Parameter, and Content Negotiation
What are the best API versioning strategies?
TL;DR
- Bottom line: Use URL path versioning (
/api/v1/resource) for public APIs due to discoverability and caching; use header versioning for internal APIs where you control all clients. - Key tool/command:
/api/v{N}/resource(URL path) orAccept: application/vnd.api.v1+json(content negotiation) - Watch out for: Versioning too eagerly — most changes can be additive (new fields, new endpoints) without a version bump.
- Works with: Any HTTP-based API framework (Express, FastAPI, Go net/http, Spring Boot, ASP.NET Core). gRPC uses protobuf package versioning instead.
Constraints
- Never introduce breaking changes to a published API version without incrementing the version identifier
- Maintain at most 2-3 active API versions simultaneously — more creates unsustainable maintenance burden
- Public APIs must use explicit versioning from day one — retrofitting versions onto an unversioned API forces all clients to migrate at once
- Sunset headers (RFC 8594) must accompany any deprecation — silent removal breaks client trust and integrations
- GraphQL APIs should use schema evolution (additive changes + deprecation directives), not URL versioning
Quick Reference
| Strategy | Format | URL Impact | Client Adoption | Caching | Routing Complexity | Best For |
|---|---|---|---|---|---|---|
| URL path | /api/v1/users | Version in URL | Trivial | Excellent | Low | Public APIs, most common |
| Query parameter | /api/users?v=1 | Param appended | Easy | Good | Low | Optional versioning, testing |
| Accept header | Accept: application/vnd.api.v1+json | None | Moderate | Poor | Medium | Internal APIs, RESTful purists |
| Custom header | X-API-Version: 1 | None | Moderate | Poor | Medium | Internal APIs, microservices |
| Content negotiation | application/vnd.company.resource.v1+json | None | Hard | Poor | High | Enterprise APIs with strict contracts |
| No versioning (evolution) | /api/users (additive only) | None | Trivial | Excellent | None | Internal APIs, rarely-changing APIs |
| Date-based | /api/2025-01-15/users | Date in URL | Moderate | Excellent | Medium | Frequent releases (Stripe model) |
| Semantic (major only) | /api/v2/users | Major version in URL | Trivial | Excellent | Low | Infrequent breaking changes |
Decision Tree
START
├── API type is GraphQL?
│ ├── YES → Use schema evolution with @deprecated directives. Do NOT use URL versioning.
│ └── NO ↓
├── API type is gRPC?
│ ├── YES → Use protobuf package versioning (package api.v1;). Route at service level.
│ └── NO ↓
├── Is this a public API with third-party consumers?
│ ├── YES → URL path versioning (/api/v1/...). Maximum discoverability.
│ │ └── Breaking changes very frequent? → Consider date-based versioning (Stripe model)
│ └── NO ↓
├── Do you control all consumers (internal/microservices)?
│ ├── YES → Header versioning (Accept or custom header) keeps URLs clean.
│ │ └── Want zero versioning overhead? → Additive-only evolution + contract tests
│ └── NO ↓
├── Is this a partner API with limited known consumers?
│ ├── YES → URL path versioning + direct migration support per partner
│ └── NO ↓
└── DEFAULT → URL path versioning (/api/v1/...) — safest, most widely understood
Step-by-Step Guide
1. Choose your versioning strategy
Evaluate your API's audience, breaking change frequency, and infrastructure. Public APIs benefit from URL path versioning for maximum discoverability. Internal APIs can use header versioning for cleaner URLs. [src1]
Decision factors:
- Public API → URL path versioning (/api/v1/)
- Internal API, all clients controlled → Header versioning or evolution
- Frequent breaking changes → Date-based versioning (Stripe model)
- GraphQL → Schema evolution only
Verify: Document your choice in your API design spec. Ensure the team agrees before implementation.
2. Implement URL path versioning with route prefixes
Structure your application with version-prefixed route groups. Each version can share common logic through service layers while keeping route handlers separate. [src7]
// Express.js — version-prefixed routers
const express = require('express');
const app = express();
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Verify: curl http://localhost:3000/api/v1/users and curl http://localhost:3000/api/v2/users return version-appropriate responses.
3. Add version middleware for header-based versioning
When using header versioning, create middleware that extracts the version from headers and routes to the correct handler. [src4]
function versionRouter(req, res, next) {
const version = req.headers['accept-version']
|| req.headers['x-api-version']
|| '1'; // default to v1
req.apiVersion = parseInt(version, 10);
next();
}
Verify: curl -H "Accept-Version: 2" http://localhost:3000/api/users returns v2 format.
4. Implement deprecation with Sunset headers
When deprecating an API version, add Sunset and Deprecation headers to every response. This gives clients programmatic notice of the retirement date. [src5]
function deprecateV1(req, res, next) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.set('Link', '</api/v2/docs>; rel="successor-version"');
next();
}
app.use('/api/v1', deprecateV1);
Verify: curl -I http://localhost:3000/api/v1/users includes Deprecation: true and Sunset: headers.
5. Set up version lifecycle management
Establish a deprecation timeline: announce 6 months before retirement, provide active migration support for 12 months, remove after 18-24 months. Track version usage to identify migration stragglers. [src2]
Timeline template:
T+0: Release v2, v1 still fully supported
T+6mo: Announce v1 deprecation, add Sunset headers
T+12mo: v1 returns Warning header, rate-limit v1 calls
T+18mo: v1 returns 410 Gone for new integrations
T+24mo: v1 fully removed
Verify: Monitor version usage in your API analytics dashboard. v1 traffic should trend toward zero before removal.
Code Examples
Node.js/Express: URL Path Versioning with Shared Services
// Input: HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses
// routes/v1/users.js
const router = require('express').Router();
const UserService = require('../../services/userService');
router.get('/users', async (req, res) => {
const users = await UserService.list(req.query);
res.json(users); // v1: flat array response
});
// routes/v2/users.js
router.get('/users', async (req, res) => {
const users = await UserService.list(req.query);
res.json({
data: users,
meta: { total: users.length, page: 1 }
}); // v2: envelope with pagination
});
Python/FastAPI: URL Path Versioning with APIRouter
# Input: HTTP requests to /api/v1/users or /api/v2/users
# Output: Version-appropriate JSON responses
from fastapi import FastAPI, APIRouter
app = FastAPI()
v1 = APIRouter(prefix="/api/v1", tags=["v1"])
v2 = APIRouter(prefix="/api/v2", tags=["v2"])
@v1.get("/users")
async def get_users_v1():
users = await fetch_users()
return users # flat list
@v2.get("/users")
async def get_users_v2():
users = await fetch_users()
return {"data": users, "meta": {"total": len(users)}}
app.include_router(v1)
app.include_router(v2)
Go: URL Path Versioning with http.ServeMux
// Input: HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses
func usersV1(w http.ResponseWriter, r *http.Request) {
users := []User{{ID: 1, Name: "Alice"}}
json.NewEncoder(w).Encode(users) // flat array
}
func usersV2(w http.ResponseWriter, r *http.Request) {
users := []User{{ID: 1, Name: "Alice"}}
resp := map[string]interface{}{
"data": users,
"meta": map[string]int{"total": len(users)},
}
json.NewEncoder(w).Encode(resp) // envelope
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/users", usersV1)
mux.HandleFunc("/api/v2/users", usersV2)
http.ListenAndServe(":8080", mux)
}
Anti-Patterns
Wrong: Breaking changes without version bump
// BAD — changing response shape in existing version
app.get('/api/v1/users', (req, res) => {
// Renamed "name" to "full_name" and wrapped in envelope
res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
// Every existing client just broke
});
Correct: New version for breaking changes
// GOOD — breaking change goes in new version
app.get('/api/v1/users', (req, res) => {
res.json([{ id: 1, name: "Alice" }]); // unchanged
});
app.get('/api/v2/users', (req, res) => {
res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
});
Wrong: Copying entire codebase per version
// BAD — duplicating all code for each version
src/
v1/controllers/ models/ services/ # full copy
v2/controllers/ models/ services/ # full copy with one change
Correct: Shared services with version-specific handlers
// GOOD — share business logic, version only the API layer
src/
services/ # shared business logic
routes/v1/ # thin route handlers calling services
routes/v2/ # thin route handlers calling services
Wrong: Versioning too eagerly
// BAD — creating new versions for additive changes
// v1: { id, name }
// v2: { id, name, email } <-- could be additive
// v3: { id, name, email, phone } <-- could be additive
// Now maintaining 3 versions unnecessarily
Correct: Additive changes without version bump
// GOOD — adding optional fields is NOT a breaking change
app.get('/api/v1/users', (req, res) => {
res.json([{ id: 1, name: "Alice", email: "[email protected]", phone: null }]);
// Old clients safely ignore email and phone
});
Common Pitfalls
- No default version: Clients that omit version info get undefined behavior. Fix: always default to the latest stable version or return
400 Bad Requestwith a version hint. [src1] - Removing fields from existing version: Removing a response field is a breaking change even if you think nobody uses it. Fix: add new fields freely, never remove — create a new version to change the shape. [src3]
- Forgetting CORS preflight for versioned headers: Custom
X-API-Versionheaders trigger CORS preflight. Fix: include the header inAccess-Control-Allow-Headersand handle OPTIONS. [src4] - No deprecation communication: Retiring a version without notice. Fix: add
Deprecation: true,Sunset: <date>, andLink: <successor-version>headers at least 6 months before removal. [src5] - Inconsistent versioning across endpoints: Some endpoints at v2, others still at v1 only. Fix: version the entire API uniformly — all endpoints advance together. [src2]
- Over-versioning: Creating a new version for every change. Fix: distinguish breaking changes (require version bump) from additive changes (new fields, new endpoints — no bump needed). [src3]
- Ignoring version usage metrics: Supporting dead versions wastes resources. Fix: track per-version request counts and sunset versions with <1% traffic. [src2]
Diagnostic Commands
# Check which API version a response is using
curl -s -D - http://localhost:3000/api/v1/users | head -20
# Test header-based versioning
curl -s -H "Accept-Version: 2" http://localhost:3000/api/users | jq .
# Check for deprecation headers
curl -s -I http://localhost:3000/api/v1/users | grep -i -E "deprecation|sunset|link"
# Count API version distribution in access logs
awk -F'"' '{print $2}' access.log | grep -oP '/api/v\d+' | sort | uniq -c | sort -rn
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Public API with third-party consumers | Internal single-consumer microservice | Contract testing (Pact) + additive evolution |
| Breaking response shape changes needed | Adding new optional fields to responses | Additive changes — no version needed |
| Multiple client generations must coexist | GraphQL API | Schema evolution with @deprecated directives |
| Regulatory or compliance requires stability | gRPC service-to-service | Protobuf package versioning |
| API monetization with SLA commitments | Prototype or MVP stage | Ship unversioned, add versioning before public launch |
| Long-lived webhooks with fixed payloads | Rapidly iterating internal tool | Feature flags or gradual rollout |
Important Caveats
- URL path versioning is the most popular strategy (used by GitHub, Google, Stripe, Twilio) but is technically not RESTful — REST purists argue the version is not part of the resource identity
- Date-based versioning (Stripe's model:
/v1withStripe-Version: 2025-01-15) requires sophisticated API gateway infrastructure to maintain many date-pinned behaviors simultaneously - Content negotiation (
Accept: application/vnd.api.v1+json) is the most RESTful approach but has the worst developer experience — hard to test in browsers, difficult to document, breaks simple curl commands - API gateways (Kong, Apigee, AWS API Gateway) can handle version routing externally, decoupling versioning from application code — but adds infrastructure complexity
- Mobile clients cannot be forced to upgrade, so mobile-facing APIs may need to support old versions for years longer than web-facing APIs