API versioning strategies — URL path, header, query param, content negotiation — with breaking change classification, deprecation timelines, migration patterns, and multi-version support. Use when evolving APIs, planning breaking changes, or managing version lifecycles. Use when this capability is needed.
Evolve your API confidently. Version correctly, deprecate gracefully, migrate safely — without breaking existing consumers.
Pick one strategy and apply it consistently across your entire API surface.
| Strategy | Format | Visibility | Cacheability | Best For |
|---|---|---|---|---|
| URL Path | /api/v1/users | High | Excellent | Public APIs, third-party integrations |
| Query Param | /api/users?v=1 | Medium | Moderate | Simple APIs, prototyping |
| Header | Accept-Version: v1 | Low | Good | Internal APIs, coordinated consumers |
| Content Negotiation |
Accept: application/vnd.api.v1+json| Low |
| Good |
| Enterprise, strict REST compliance |
The most common strategy. Version lives in the URL, making it immediately visible.
from fastapi import FastAPI, APIRouter
v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")
@v1.get("/users")
async def list_users_v1():
return {"users": [...]}
@v2.get("/users")
async def list_users_v2():
return {"data": {"users": [...]}, "meta": {...}}
app = FastAPI()
app.include_router(v1)
app.include_router(v2)
Rules:
/api/v1/... not /v1/api/.../api/v1/, never /api/v1.2/ or /api/v1.2.3/Version specified via request headers, keeping URLs clean.
function versionRouter(req, res, next) {
const version = req.headers['accept-version'] || 'v2'; // default to latest
req.apiVersion = version;
next();
}
app.get('/api/users', versionRouter, (req, res) => {
if (req.apiVersion === 'v1') return res.json({ users: [...] });
if (req.apiVersion === 'v2') return res.json({ data: { users: [...] }, meta: {} });
return res.status(400).json({ error: `Unsupported version: ${req.apiVersion}` });
});
Always define fallback behavior when no version header is sent — default to latest stable or return 400 Bad Request.
| SemVer Component | API Meaning | Action Required |
|---|---|---|
| MAJOR (v1 → v2) | Breaking changes — remove field, rename endpoint, change auth | Clients must migrate |
| MINOR (v1.1 → v1.2) | Additive, backward-compatible — new optional field, new endpoint | No client changes |
| PATCH (v1.1.0 → v1.1.1) | Bug fixes, no behavior change | No client changes |
Only MAJOR versions appear in URL paths. Communicate MINOR and PATCH through changelogs.
| Change | Why It Breaks |
|---|---|
| Remove a response field | Clients reading that field get undefined |
| Rename a field | Same as removal from the client's perspective |
| Change a field's type | "id": 123 → "id": "123" breaks typed clients |
| Remove an endpoint | Clients calling it get 404 |
| Make optional param required | Existing requests missing it start failing |
| Change URL structure | Bookmarked/hardcoded URLs break |
| Change error response format | Client error-handling logic breaks |
| Change authentication mechanism | Existing credentials stop working |
| Change | Why It's Safe |
|---|---|
| Add new optional response field | Clients ignore unknown fields |
| Add new endpoint | Doesn't affect existing endpoints |
| Add new optional query/body param | Existing requests work without it |
| Add new enum value | Safe if clients handle unknown values gracefully |
| Relax a validation constraint | Previously valid requests remain valid |
| Improve performance | Same interface, faster response |
Never remove a version without warning. Follow this timeline:
Phase 1: ANNOUNCE
• Sunset header on responses • Changelog entry
• Email/webhook to consumers • Docs marked "deprecated"
Phase 2: SUNSET PERIOD
• v1 still works but warns • Monitor v1 traffic
• Contact remaining consumers • Provide migration support
Phase 3: REMOVAL
• v1 returns 410 Gone
• Response body includes migration guide URL
• Redirect docs to v2
Minimum deprecation periods: Public API: 12 months · Partner API: 6 months · Internal API: 1–3 months
Include on every response from a deprecated version:
HTTP/1.1 200 OK
Sunset: Sat, 01 Mar 2025 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/docs/migrate-v1-v2>; rel="sunset"
X-API-Warn: "v1 is deprecated. Migrate to v2 by 2025-03-01."
When past sunset, return 410 Gone:
{
"error": "VersionRetired",
"message": "API v1 was retired on 2025-03-01.",
"migration_guide": "https://api.example.com/docs/migrate-v1-v2",
"current_version": "v2"
}
Shared business logic, version-specific serialization:
class UserService:
async def get_user(self, user_id: str) -> User:
return await self.repo.find(user_id)
def to_v1(user: User) -> dict:
return {"id": user.id, "name": user.full_name, "email": user.email}
def to_v2(user: User) -> dict:
return {
"id": user.id,
"name": {"first": user.first_name, "last": user.last_name},
"emails": [{"address": e, "primary": i == 0} for i, e in enumerate(user.emails)],
"created_at": user.created_at.isoformat(),
}
Single entry point delegates to the correct versioned handler:
async def get_user(user_id: str, version: int):
user = await user_service.get_user(user_id)
serializers = {1: to_v1, 2: to_v2}
serialize = serializers.get(version)
if not serialize:
raise UnsupportedVersionError(version)
return serialize(user)
Separate controller files per version, shared service layer:
api/
v1/
users.py # v1 request/response shapes
orders.py
v2/
users.py # v2 request/response shapes
orders.py
services/
user_service.py # version-agnostic business logic
order_service.py
Route versions at infrastructure layer: