Editor's Note
api-api-versioning
Planning API changes and deciding on versioning approach
Install
npx skills add https://github.com/tools-only/X-Skills --skill api-api-versioningAPI Versioning
Scope: API versioning strategies, breaking changes, deprecation workflow, migration patterns Lines: ~200 Last Updated: 2025-10-18
When to Use This Skill
Activate this skill when:
- Planning API changes and deciding on versioning approach
- Managing multiple API versions in production
- Deprecating old API versions
- Migrating clients to new API versions
- Designing backward compatibility strategies
- Implementing breaking changes safely
- Establishing sunset policies for legacy APIs
Core Concepts
What is API Versioning?
Problem: APIs evolve over time. Changes can break existing clients.
Solution: Maintain multiple API versions simultaneously, allowing clients to migrate gradually.
Benefits:
- Backward compatibility for existing clients
- Safe deployment of breaking changes
- Gradual client migration
- Clear contract between API and clients
Cost:
- Maintenance burden (multiple codebases)
- Testing complexity
- Documentation overhead
Versioning Strategies
1. URL Path Versioning
Format: /v1/users, /v2/users
Example:
GET /v1/users/123
GET /v2/users/123
Implementation (FastAPI):
from fastapi import FastAPI
app = FastAPI()
# Version 1
@app.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int):
return {"id": user_id, "name": "Alice"}
# Version 2 (with email field)
@app.get("/v2/users/{user_id}")
async def get_user_v2(user_id: int):
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
Pros:
- Simple and explicit
- Easy to route and cache
- Visible in logs and monitoring
- Works with all HTTP clients
Cons:
- URL changes break bookmarks
- Version in URL feels unnatural
- No granular versioning per endpoint
Best for: Public APIs, REST APIs, simple versioning schemes
2. Header Versioning
Format: Accept: application/vnd.api.v1+json
Example:
GET /users/123
Accept: application/vnd.api.v2+json
Implementation (FastAPI):
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
accept: str = Header(default="application/vnd.api.v1+json")
):
if "v2" in accept:
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
elif "v1" in accept:
return {"id": user_id, "name": "Alice"}
else:
raise HTTPException(400, "Unsupported API version")
Pros:
- Clean URLs (no version pollution)
- RESTful (resource unchanged, representation varies)
- Granular control per request
Cons:
- Less discoverable (hidden in headers)
- Harder to test (need header support)
- Caching complexity
Best for: Internal APIs, strict REST adherence, content negotiation
3. Query Parameter Versioning
Format: /users?api_version=2
Example:
GET /users/123?api_version=2
Implementation (FastAPI):
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
api_version: int = Query(default=1)
):
if api_version == 2:
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
else:
return {"id": user_id, "name": "Alice"}
Pros:
- Simple to implement
- Easy to test (just add parameter)
- Works with all clients
Cons:
- Version in query string feels unnatural
- Pollutes URL space
- Can conflict with other parameters
Best for: Simple internal APIs, quick prototypes
4. Custom Header Versioning
Format: X-API-Version: 2
Example:
GET /users/123
X-API-Version: 2
Implementation (FastAPI):
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
x_api_version: int = Header(default=1)
):
if x_api_version == 2:
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
else:
return {"id": user_id, "name": "Alice"}
Pros:
- Clean URLs
- Explicit versioning
- Easy to add middleware
Cons:
- Custom headers not standard
- Requires client header support
- Less discoverable
Best for: Internal APIs, microservices, controlled environments
Breaking vs Non-Breaking Changes
Non-Breaking Changes (Safe)
Can deploy without versioning:
✅ Adding optional fields to request:
// Before
{"name": "Alice"}
// After (optional email)
{"name": "Alice", "email": "alice@example.com"}
✅ Adding new fields to response:
// Before
{"id": 1, "name": "Alice"}
// After (added email)
{"id": 1, "name": "Alice", "email": "alice@example.com"}
✅ Adding new endpoints:
POST /v1/users (existing)
POST /v1/users/bulk (new, safe)
✅ Adding new optional query parameters ✅ Expanding enum values (if clients ignore unknown) ✅ Relaxing validation (accepting more input)
Breaking Changes (Require Versioning)
Must introduce new version:
❌ Removing fields from response:
// Before
{"id": 1, "name": "Alice", "email": "alice@example.com"}
// After (removed email) - BREAKS CLIENTS
{"id": 1, "name": "Alice"}
❌ Renaming fields:
// Before
{"user_id": 1}
// After - BREAKS CLIENTS
{"id": 1}
❌ Changing field types:
// Before
{"created_at": "2025-01-15"}
// After - BREAKS CLIENTS
{"created_at": 1736899200}
❌ Making optional field required ❌ Removing endpoints ❌ Changing URL structure ❌ Stricter validation (rejecting previously valid input) ❌ Changing authentication scheme
Breaking Change Checklist
Before introducing breaking change:
[ ] Identified all affected clients
[ ] Planned new version number/identifier
[ ] Implemented both old and new versions
[ ] Created migration guide for clients
[ ] Set deprecation timeline (e.g., 6 months)
[ ] Added deprecation warnings to old version
[ ] Updated documentation
[ ] Communicated timeline to stakeholders
[ ] Monitored usage of old version
[ ] Planned sunset date for old version
Deprecation Workflow
Phase 1: Announce (Month 0)
Actions:
- Document breaking changes
- Announce deprecation timeline
- Add deprecation headers to old version
Example (FastAPI):
from fastapi import Response
@app.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int, response: Response):
# Add deprecation warning
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "2025-07-01"
response.headers["Link"] = '</v2/users>; rel="successor-version"'
return {"id": user_id, "name": "Alice"}
Communication:
Subject: API v1 Deprecation Notice
We're deprecating /v1/users in favor of /v2/users.
Timeline:
- Today: v2 available, v1 still supported
- Month 3: Deprecation warnings added to v1
- Month 6: v1 sunset (will return 410 Gone)
Migration guide: https://docs.api.com/v1-to-v2
Questions? Contact api-support@example.com
Phase 2: Warn (Month 3)
Actions:
- Log clients still using old version
- Send targeted emails to heavy users
- Add warning in response body
Example:
@app.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int, response: Response):
response.headers["X-Deprecation-Warning"] = "v1 will sunset on 2025-07-01"
return {
"id": user_id,
"name": "Alice",
"_deprecation": {
"message": "This endpoint will be removed on 2025-07-01",
"migration_guide": "https://docs.api.com/v1-to-v2"
}
}
Phase 3: Sunset (Month 6)
Actions:
- Return
410 Gonefor old version - Redirect to migration guide
- Monitor for stragglers
Example:
@app.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int):
raise HTTPException(
status_code=410,
detail={
"error": "This API version has been sunset",
"sunset_date": "2025-07-01",
"migration_guide": "https://docs.api.com/v1-to-v2",
"new_endpoint": "/v2/users/{user_id}"
}
)
Deprecation Timeline Template
Month 0: Announcement
- Release v2 alongside v1
- Announce deprecation timeline
- Update documentation
Month 1-2: Migration Period
- Monitor v1 usage
- Provide migration support
- Send reminder emails
Month 3: Warning Phase
- Add deprecation headers
- Log clients using v1
- Contact heavy users directly
Month 4-5: Final Warning
- Increase warning visibility
- Offer migration assistance
- Set hard sunset date
Month 6: Sunset
- Return 410 Gone for v1
- Redirect to migration guide
- Monitor for issues
Adjust timeline based on:
- API criticality (longer for critical APIs)
- Number of clients (longer for many clients)
- Migration complexity (longer for complex changes)
Recommended timelines:
- Internal APIs: 3 months
- Public APIs: 6-12 months
- Critical APIs: 12-24 months
Version Migration Patterns
Pattern 1: Parallel Versions
Strategy: Run old and new versions side-by-side.
# v1/users.py
@router.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int):
return {"id": user_id, "name": "Alice"}
# v2/users.py
@router.get("/v2/users/{user_id}")
async def get_user_v2(user_id: int):
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
Pros: Clean separation, easy rollback Cons: Code duplication, maintenance burden
Pattern 2: Adapter Pattern
Strategy: Single core implementation, adapters for each version.
# core/users.py
def get_user_data(user_id: int):
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
# api/v1.py
@app.get("/v1/users/{user_id}")
async def get_user_v1(user_id: int):
data = get_user_data(user_id)
# Adapter: remove email for v1
return {"id": data["id"], "name": data["name"]}
# api/v2.py
@app.get("/v2/users/{user_id}")
async def get_user_v2(user_id: int):
return get_user_data(user_id) # Full data
Pros: Single source of truth, less duplication Cons: Adapter complexity increases over time
Pattern 3: Feature Flags
Strategy: Use flags to toggle new behavior.
from functools import wraps
def version_aware(func):
@wraps(func)
async def wrapper(user_id: int, version: int = 1):
data = await func(user_id)
if version == 1:
# Remove new fields for v1
return {k: v for k, v in data.items() if k in ["id", "name"]}
else:
return data
return wrapper
@version_aware
async def get_user(user_id: int):
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com"
}
Pros: Single endpoint, flexible Cons: Complexity in logic, harder to test
Backward Compatibility Techniques
Technique 1: Additive Changes Only
Rule: Only add, never remove or change.
// Version 1
{"id": 1, "name": "Alice"}
// Version 1.1 (backward compatible)
{"id": 1, "name": "Alice", "email": "alice@example.com"}
// Version 1.2 (still backward compatible)
{"id": 1, "name": "Alice", "email": "alice@example.com", "phone": "555-1234"}
Clients ignore unknown fields → No breaking changes.
Technique 2: Default Values
Strategy: Provide defaults for new required fields.
from pydantic import BaseModel, Field
class User(BaseModel):
id: int
name: str
email: str = Field(default="noreply@example.com") # Default for old clients
Old requests (no email) → Use default New requests (with email) → Use provided value
Technique 3: Field Aliases
Strategy: Support both old and new field names.
from pydantic import BaseModel, Field
class User(BaseModel):
user_id: int = Field(alias="id") # Accept both "id" and "user_id"
name: str
Accepts:
{"id": 1, "name": "Alice"} // Old clients
{"user_id": 1, "name": "Alice"} // New clients
Versioning Strategy Comparison
| Strategy | Discoverability | Caching | Simplicity | Best For |
|---|---|---|---|---|
| URL Path | ⭐⭐⭐ High | ⭐⭐⭐ Easy | ⭐⭐⭐ Simple | Public APIs, REST |
| Header (Accept) | ⭐ Low | ⭐ Complex | ⭐⭐ Moderate | Strict REST, Internal |
| Query Param | ⭐⭐ Medium | ⭐⭐ Moderate | ⭐⭐⭐ Simple | Prototypes, Internal |
| Custom Header | ⭐ Low | ⭐⭐ Moderate | ⭐⭐ Moderate | Microservices, Internal |
Recommendation: Use URL Path Versioning for simplicity and discoverability unless strict REST compliance required.
Related Skills
fastapi-routing.md- Organizing API routes and versioningapi-design-patterns.md- RESTful API design principlesapi-documentation.md- Documenting versioned APIs (OpenAPI/Swagger)database-migrations.md- Versioning database schemas alongside APIsfeature-flags.md- Using feature flags for gradual rollouts
Last Updated: 2025-10-18 Format Version: 1.0 (Atomic)