Every API will change. Features get added, designs get improved, and sometimes you need breaking changes. The question isn’t whether to version—it’s how.
Different versioning strategies have different tradeoffs. Here’s how to choose.
Why Version APIs
The Backward Compatibility Problem
Client v1 expects: { "name": "Alice" }
API v2 returns: { "full_name": "Alice", "display_name": "alice" }
Result: Client breaks
Without versioning, API changes break existing clients. With versioning, clients can upgrade on their own schedule.
When You Need Breaking Changes
Field changes:
- Renaming fields
- Changing types
- Removing fields
Structural changes:
- Changing response format
- Modifying relationships
- Changing error formats
Behavioral changes:
- Different default values
- Changed validation rules
- Modified side effects
Versioning Strategies
URL Path Versioning
GET /api/v1/users/123
GET /api/v2/users/123
Pros:
- Highly visible
- Easy to implement
- Clear routing
- Easy caching
Cons:
- Not semantically correct (version isn’t a resource)
- Can lead to code duplication
- URLs change on version bump
Implementation:
// Router setup
router.PathPrefix("/api/v1").Handler(v1.Routes())
router.PathPrefix("/api/v2").Handler(v2.Routes())
Query Parameter Versioning
GET /api/users/123?version=1
GET /api/users/123?version=2
Pros:
- URL stays the same
- Optional parameter
- Easy to test
Cons:
- Less discoverable
- Caching more complex
- Easy to forget
Implementation:
@app.route('/api/users/<id>')
def get_user(id):
version = request.args.get('version', '2') # Default to latest
if version == '1':
return serialize_user_v1(user)
else:
return serialize_user_v2(user)
Header Versioning
GET /api/users/123
Accept: application/vnd.myapp.v2+json
Or custom header:
GET /api/users/123
X-API-Version: 2
Pros:
- Clean URLs
- Semantically correct
- Flexible content negotiation
Cons:
- Less visible
- Harder to test in browser
- Easy to misconfigure
Implementation:
@app.route('/api/users/<id>')
def get_user(id):
accept = request.headers.get('Accept', '')
if 'vnd.myapp.v1' in accept:
return serialize_user_v1(user), {'Content-Type': 'application/vnd.myapp.v1+json'}
else:
return serialize_user_v2(user), {'Content-Type': 'application/vnd.myapp.v2+json'}
No Explicit Versioning (Evolutionary)
Add fields without removing. Never break backward compatibility.
// v1 client sees what it expects
{ "name": "Alice" }
// v2 client sees additional fields
{ "name": "Alice", "display_name": "alice", "avatar_url": "..." }
Pros:
- Simplest for clients
- No version management
- Gradual evolution
Cons:
- Constrains design
- Can’t remove fields
- Can’t change field semantics
- Accumulates cruft
Choosing a Strategy
Decision Framework
┌─────────────────────────────────────────────────────────────┐
│ API Versioning Decision │
├─────────────────────────────────────────────────────────────┤
│ │
│ Q1: Is the API public or internal? │
│ └── Internal only → Consider evolutionary │
│ └── Public → Need explicit versioning │
│ │
│ Q2: How often will breaking changes happen? │
│ └── Rarely → Evolutionary may work │
│ └── Frequently → Need robust versioning │
│ │
│ Q3: Can you coordinate client updates? │
│ └── Yes → Simpler versioning OK │
│ └── No → Need long-term version support │
│ │
│ Q4: Developer experience priority? │
│ └── High → URL versioning (most discoverable) │
│ └── Medium → Header versioning │
│ │
└─────────────────────────────────────────────────────────────┘
Common Choices
Public APIs: URL path versioning
- Most discoverable
- Clear documentation
- Easy for developers
Internal APIs: Evolutionary or header versioning
- Can coordinate changes
- Cleaner URLs
- Flexibility
Mobile apps: URL or header versioning
- Need to support old app versions
- Users don’t update immediately
- Multiple versions in production
Implementation Patterns
Version Handlers
# Separate handlers per version
class UserAPIv1:
def get_user(self, user_id):
user = self.db.get_user(user_id)
return {
'id': user.id,
'name': user.name,
'email': user.email
}
class UserAPIv2:
def get_user(self, user_id):
user = self.db.get_user(user_id)
return {
'id': user.id,
'full_name': user.name,
'display_name': user.display_name,
'email_address': user.email,
'avatar': user.avatar_url
}
Serializers/Transformers
# Shared logic, different serialization
def get_user(user_id):
user = db.get_user(user_id)
return user
def serialize_user_v1(user):
return {
'id': user.id,
'name': user.name,
'email': user.email
}
def serialize_user_v2(user):
return {
'id': user.id,
'full_name': user.name,
'display_name': user.display_name,
'email_address': user.email,
'avatar': user.avatar_url
}
Versioned Models
# Pydantic models per version
from pydantic import BaseModel
class UserResponseV1(BaseModel):
id: int
name: str
email: str
class UserResponseV2(BaseModel):
id: int
full_name: str
display_name: str
email_address: str
avatar: Optional[str]
Deprecation Strategy
Lifecycle
Active → Deprecated → Sunset → Removed
Timeline example:
v1: Active (current)
v2: Released
v1: Deprecated (6 months notice)
v1: Sunset (read-only or reduced SLA)
v1: Removed
Communication
# Deprecation headers
HTTP/1.1 200 OK
Deprecation: Sun, 01 Jan 2021 00:00:00 GMT
Sunset: Mon, 01 Jul 2021 00:00:00 GMT
Link: </api/v2/users/123>; rel="successor-version"
Documentation
## API v1 (Deprecated)
⚠️ **Deprecation Notice**: API v1 is deprecated and will be removed on July 1, 2021.
Please migrate to [API v2](/docs/api/v2).
### Migration Guide
- `name` → `full_name`
- `email` → `email_address`
- New field: `display_name`
- New field: `avatar`
Multi-Version Maintenance
Code Organization
api/
├── v1/
│ ├── handlers/
│ ├── serializers/
│ └── routes.py
├── v2/
│ ├── handlers/
│ ├── serializers/
│ └── routes.py
├── shared/
│ ├── models/
│ ├── services/
│ └── utils/
└── main.py
Shared Logic
# Business logic is shared
class UserService:
def get_user(self, user_id):
return self.repository.find(user_id)
def create_user(self, data):
# Validation, business rules
return self.repository.create(data)
# Only serialization differs
# v1/serializers.py
def serialize_user(user):
return {'name': user.name, ...}
# v2/serializers.py
def serialize_user(user):
return {'full_name': user.name, ...}
Testing Multiple Versions
# Test both versions
@pytest.mark.parametrize("version,expected_field", [
("v1", "name"),
("v2", "full_name"),
])
def test_get_user_returns_name(version, expected_field, client):
response = client.get(f'/api/{version}/users/1')
assert expected_field in response.json()
Breaking Changes Without Versioning
Additive Changes
Safe without versioning:
- Adding new fields
- Adding new endpoints
- Adding optional parameters
// Before
{ "name": "Alice" }
// After (backward compatible)
{ "name": "Alice", "avatar": "https://..." }
Field Aliases
Support old and new names temporarily:
def serialize_user(user, include_legacy=True):
response = {
'full_name': user.name, # New field
}
if include_legacy:
response['name'] = user.name # Keep old field
return response
Expand/Contract Pattern
1. Expand: Add new field, keep old
2. Migrate: Update clients to use new field
3. Contract: Remove old field
Key Takeaways
- Choose versioning strategy based on audience (public vs internal) and change frequency
- URL path versioning is most common for public APIs—it’s discoverable and simple
- Header versioning is cleaner but less visible; good for internal APIs
- Evolutionary (additive only) works for stable APIs with coordinated clients
- Share business logic between versions; only serialize differently
- Deprecate with clear timelines and communication
- Support at least 2 versions simultaneously during transitions
- Test all supported versions in CI/CD
- Document migration paths clearly
API versioning is about respecting your clients. Make upgrades possible, not forced.