Every successful API eventually needs to change in ways that break clients. How you handle versioning determines whether these changes are manageable or catastrophic. There’s no perfect approach—each has trade-offs.
Here are the versioning strategies that scale.
Why Versioning Matters
The Evolution Problem
api_evolution_reality:
changes_happen:
- Requirements evolve
- Better designs emerge
- Security fixes require changes
- Performance optimizations
clients_exist:
- Mobile apps with slow update cycles
- Third-party integrations
- Enterprise customers with contracts
- Internal services with dependencies
conflict:
- You need to change
- Clients can't change instantly
- Breaking changes break trust
Breaking vs. Non-Breaking
non_breaking_changes:
safe:
- Adding new endpoints
- Adding optional fields to responses
- Adding optional parameters
- New enum values (if clients handle unknown)
- Performance improvements
implementation:
- No version bump needed
- Additive changes preferred
- Clients should ignore unknown fields
breaking_changes:
dangerous:
- Removing endpoints
- Removing/renaming fields
- Changing field types
- Making optional fields required
- Changing behavior
requires:
- Version bump
- Migration period
- Client communication
Versioning Strategies
URL Path Versioning
url_versioning:
format: /api/v1/users, /api/v2/users
example:
v1: GET /api/v1/users/123
v2: GET /api/v2/users/123
pros:
- Very explicit
- Easy to understand
- Easy to route
- Cache-friendly
cons:
- URL changes when version changes
- Can lead to code duplication
- "Ugly" to some
when_to_use:
- Public APIs
- Major version changes
- When clarity is priority
# Flask implementation
from flask import Blueprint
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')
@v1.route('/users/<int:user_id>')
def get_user_v1(user_id):
user = User.query.get(user_id)
return {'name': user.name} # v1 response
@v2.route('/users/<int:user_id>')
def get_user_v2(user_id):
user = User.query.get(user_id)
return {
'full_name': user.name, # Renamed field
'email': user.email, # New field
'created_at': user.created_at.isoformat()
}
Header Versioning
header_versioning:
format: Accept: application/vnd.api+json;version=2
custom_header:
format: X-API-Version: 2
example: |
GET /api/users/123
X-API-Version: 2
accept_header:
format: Accept: application/vnd.company.v2+json
example: |
GET /api/users/123
Accept: application/vnd.company.v2+json
pros:
- Clean URLs
- Follows HTTP semantics (Accept header)
- Fine-grained control
cons:
- Less visible
- Harder to test (need headers)
- Not cacheable without Vary header
- Clients may forget headers
when_to_use:
- When URL cleanliness matters
- Internal APIs
- Sophisticated clients
# Header versioning implementation
from flask import request
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
version = request.headers.get('X-API-Version', '1')
user = User.query.get(user_id)
if version == '2':
return {'full_name': user.name, 'email': user.email}
else:
return {'name': user.name}
Query Parameter Versioning
query_versioning:
format: /api/users?version=2
pros:
- Optional with default
- Easy to test in browser
- Single URL structure
cons:
- Pollutes query params
- Caching complications
- Can be forgotten
when_to_use:
- Transitional periods
- Optional versioning
- When simplicity matters
Content Negotiation
content_negotiation:
format: |
GET /api/users/123
Accept: application/vnd.company.user.v2+json
response_type:
version_in_content_type: Content-Type: application/vnd.company.user.v2+json
pros:
- Most RESTful approach
- Flexible
- Can version individual resources
cons:
- Complex for clients
- Requires good documentation
- Tooling support varies
Migration Strategies
Parallel Versions
parallel_operation:
description: Run both versions simultaneously
approach:
- v1 and v2 endpoints both active
- Different code paths or services
- Gradually migrate clients
timeline:
- Day 0: v2 released alongside v1
- Month 1: Notify v1 deprecation
- Month 3: v1 deprecated (warnings)
- Month 6: v1 sunset
implementation:
option_1: Separate controllers/handlers
option_2: Version adapter layer
option_3: Separate services (microservices)
Adapter Pattern
# Version adapter pattern
class UserResponseAdapter:
@staticmethod
def to_v1(user):
return {
'name': user.name,
'id': user.id
}
@staticmethod
def to_v2(user):
return {
'full_name': user.name,
'email': user.email,
'id': user.id,
'created_at': user.created_at.isoformat()
}
@app.route('/api/v1/users/<int:user_id>')
def get_user_v1(user_id):
user = User.query.get(user_id)
return UserResponseAdapter.to_v1(user)
@app.route('/api/v2/users/<int:user_id>')
def get_user_v2(user_id):
user = User.query.get(user_id)
return UserResponseAdapter.to_v2(user)
Deprecation Communication
deprecation_headers:
sunset_header: |
Sunset: Sat, 31 Dec 2022 23:59:59 GMT
deprecation_header: |
Deprecation: true
link_to_docs: |
Link: <https://api.example.com/docs/migration>; rel="deprecation"
example_response:
headers:
Deprecation: "true"
Sunset: "Sat, 31 Dec 2022 23:59:59 GMT"
Link: '<https://api.example.com/v2/users>; rel="successor-version"'
// Response body deprecation warning
{
"data": { ... },
"_meta": {
"deprecated": true,
"sunset_date": "2022-12-31",
"migration_guide": "https://api.example.com/docs/v1-to-v2"
}
}
Design for Evolution
Extensible Schemas
extensible_design:
wrap_responses:
bad: '[{"id": 1}, {"id": 2}]'
good: '{"data": [{"id": 1}, {"id": 2}]}'
why: Can add metadata without breaking
use_objects:
bad: '"2022-03-07"'
good: '{"date": "2022-03-07", "timezone": "UTC"}'
why: Can add properties later
avoid_primitives_for_ids:
bad: '{"user": 123}'
good: '{"user": {"id": 123}}'
why: Can add user info without breaking
Tolerant Reader Pattern
client_tolerance:
principle: |
Clients should ignore fields they don't recognize
and handle missing optional fields gracefully
client_implementation:
- Parse only known fields
- Don't fail on unknown fields
- Have defaults for missing fields
benefit:
- New fields are non-breaking
- Servers can evolve freely
- Clients don't break on additions
Testing Versioned APIs
testing_strategy:
contract_tests:
- Test each version's contract
- Ensure backward compatibility
- Detect unintentional breaks
version_matrix:
- Test client v1 with server v1, v2
- Ensure graceful handling
deprecation_tests:
- Verify deprecation headers present
- Test sunset behavior
# Contract test example
def test_v1_response_format():
response = client.get('/api/v1/users/1')
data = response.json()
# v1 contract
assert 'name' in data
assert 'email' not in data # Not in v1
def test_v2_response_format():
response = client.get('/api/v2/users/1')
data = response.json()
# v2 contract
assert 'full_name' in data
assert 'email' in data
assert 'name' not in data # Removed in v2
Key Takeaways
- API versioning is inevitable; plan for it from the start
- Prefer non-breaking changes: add fields, don’t remove
- URL versioning is clearest; use for public APIs
- Header versioning is cleaner but less discoverable
- Run versions in parallel during migration
- Use adapter pattern to share core logic
- Communicate deprecation clearly: headers, docs, timelines
- Design schemas for extension: wrap in objects
- Clients should ignore unknown fields (tolerant reader)
- Test both versions and the migration path
- Set and honor sunset dates
Version wisely. Every version you add is a version you maintain.