API Versioning Strategies: Choosing the Right Approach

February 3, 2020

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:

Structural changes:

Behavioral changes:

Versioning Strategies

URL Path Versioning

GET /api/v1/users/123
GET /api/v2/users/123

Pros:

Cons:

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:

Cons:

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:

Cons:

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:

Cons:

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

Internal APIs: Evolutionary or header versioning

Mobile apps: URL or header versioning

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:

// 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

API versioning is about respecting your clients. Make upgrades possible, not forced.