Every API eventually needs to change in ways that break existing clients. The question isn’t whether to version — it’s how to version in a way that doesn’t make your users hate you.

Here’s what actually works, with real trade-offs.

The Three Schools of Versioning

URL Path Versioning

GGEETT//aappii//v12//uusseerrss//112233

Pros:

  • Obvious and discoverable
  • Easy to route at load balancer level
  • Cache-friendly (different URLs = different cache keys)
  • Simple to document

Cons:

  • Ugly URLs (purists will complain)
  • Clients must update URLs to migrate
  • Encourages big-bang version bumps
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 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 {
        'id': user.id,
        'name': user.name,
        'email': user.email  # v1 exposes email
    }

@v2.route('/users/<int:user_id>')
def get_user_v2(user_id):
    user = User.query.get(user_id)
    return {
        'id': user.id,
        'displayName': user.name,  # Renamed field
        # email removed for privacy
        'avatarUrl': user.avatar_url  # New field
    }

app.register_blueprint(v1)
app.register_blueprint(v2)

Header Versioning

GAEcTce/patp:i/aupspelrisc/a1t2i3on/vnd.myapi.v2+json

Or custom header:

GXE-TAP/Ia-pVie/russieorns:/1223

Pros:

  • Clean URLs
  • Semantic (version is metadata, not resource identity)
  • Easy to default to latest

Cons:

  • Harder to test (can’t just paste URL in browser)
  • Easy to forget the header
  • Caching gets complicated
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from flask import request, jsonify

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    version = request.headers.get('X-API-Version', '2')
    user = User.query.get(user_id)
    
    if version == '1':
        return jsonify({
            'id': user.id,
            'name': user.name,
            'email': user.email
        })
    else:
        return jsonify({
            'id': user.id,
            'displayName': user.name,
            'avatarUrl': user.avatar_url
        })

Query Parameter Versioning

GET/api/users/123?version=2

Pros:

  • Easy to test
  • Optional (can default)
  • Works with any HTTP client

Cons:

  • Pollutes query string
  • Cache key includes query params (might be fine)
  • Feels hacky to some
1
2
3
4
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    version = request.args.get('version', '2')
    # ... same logic as header versioning

My Recommendation: URL Path + Sunset Headers

Use URL paths for major versions. Use headers to communicate deprecation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from flask import Flask, request, make_response
from datetime import datetime, timedelta

app = Flask(__name__)

V1_SUNSET = datetime(2024, 6, 1)
V1_DEPRECATED = True

@app.route('/api/v1/users/<int:user_id>')
def get_user_v1(user_id):
    response = make_response(get_user_data_v1(user_id))
    
    if V1_DEPRECATED:
        response.headers['Deprecation'] = 'true'
        response.headers['Sunset'] = V1_SUNSET.strftime('%a, %d %b %Y %H:%M:%S GMT')
        response.headers['Link'] = '</api/v2/users>; rel="successor-version"'
    
    return response

Clients that pay attention to headers get advance warning. Clients that don’t get broken on sunset date (and it’s their fault, not yours).

Avoiding Breaking Changes

The best version increment is the one you don’t need. Most “breaking changes” can be made backward-compatible.

Adding Fields (Safe)

1
2
3
4
5
// Before
{"id": 1, "name": "Alice"}

// After - existing clients ignore new fields
{"id": 1, "name": "Alice", "avatarUrl": "/avatars/1.png"}

Removing Fields (Breaking)

Don’t remove. Deprecate, then stop populating.

1
2
3
4
5
6
7
8
// Phase 1: Mark deprecated in docs, keep returning
{"id": 1, "name": "Alice", "email": "alice@example.com"}

// Phase 2: Return null/empty (after warning period)
{"id": 1, "name": "Alice", "email": null}

// Phase 3: Remove in next major version
{"id": 1, "name": "Alice"}

Renaming Fields (Breaking)

Return both names during transition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def serialize_user(user, include_legacy=True):
    data = {
        'id': user.id,
        'displayName': user.name,  # New name
    }
    
    if include_legacy:
        data['name'] = user.name  # Old name, deprecated
    
    return data

Changing Field Types (Breaking)

Add a new field instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Before: price as number
{"price": 19.99}

// After: add structured price, keep old
{
  "price": 19.99,
  "priceDetails": {
    "amount": 1999,
    "currency": "USD",
    "formatted": "$19.99"
  }
}

Adding Required Parameters (Breaking)

Make them optional with sensible defaults:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Before
@app.route('/api/search')
def search():
    query = request.args['q']
    return do_search(query)

# After - new parameter with default
@app.route('/api/search')
def search():
    query = request.args['q']
    limit = request.args.get('limit', 20)  # Optional with default
    include_archived = request.args.get('include_archived', 'false')  # Optional
    return do_search(query, limit, include_archived == 'true')

Version Lifecycle Management

The Four Stages

AdCceutvriervleeonptmentDDieinpsrcdeoocucarstaegdedRLSeiuamndis-teoetndly/RGeomnoeved

Timeline Template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# api-versions.yaml
versions:
  v3:
    status: current
    released: 2024-01-15
    
  v2:
    status: deprecated
    released: 2023-01-10
    deprecated: 2024-01-15
    sunset: 2024-07-15
    migration_guide: /docs/v2-to-v3-migration
    
  v1:
    status: removed
    released: 2022-01-01
    deprecated: 2023-01-10
    sunset: 2023-07-10
    removed: 2023-07-10

Communicating Deprecation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import logging
from collections import defaultdict
from datetime import datetime

deprecation_warnings = defaultdict(int)

def track_deprecated_usage(version, endpoint, client_id):
    """Track who's still using deprecated versions"""
    key = f"{version}:{endpoint}:{client_id}"
    deprecation_warnings[key] += 1
    
    # Log periodically, not every request
    if deprecation_warnings[key] == 1:
        logging.warning(
            f"Client {client_id} using deprecated {version} endpoint {endpoint}"
        )

@app.route('/api/v1/users/<int:user_id>')
def get_user_v1(user_id):
    client_id = request.headers.get('X-Client-ID', 'unknown')
    track_deprecated_usage('v1', '/users', client_id)
    
    # ... handle request

Client-Side Version Handling

Good clients handle version negotiation gracefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import requests
from packaging import version

class APIClient:
    def __init__(self, base_url, preferred_version='v2'):
        self.base_url = base_url
        self.preferred_version = preferred_version
        self.session = requests.Session()
    
    def request(self, method, path, **kwargs):
        url = f"{self.base_url}/api/{self.preferred_version}{path}"
        response = self.session.request(method, url, **kwargs)
        
        # Check for deprecation warnings
        if response.headers.get('Deprecation') == 'true':
            sunset = response.headers.get('Sunset')
            successor = response.headers.get('Link')
            logging.warning(
                f"API version {self.preferred_version} deprecated. "
                f"Sunset: {sunset}. Upgrade to: {successor}"
            )
        
        return response
    
    def get_user(self, user_id):
        response = self.request('GET', f'/users/{user_id}')
        data = response.json()
        
        # Handle field name changes gracefully
        return {
            'id': data['id'],
            'name': data.get('displayName') or data.get('name'),
            'avatar': data.get('avatarUrl')
        }

GraphQL: A Different Approach

GraphQL sidesteps versioning by making all fields explicitly requested:

1
2
3
4
5
6
7
8
# Clients request exactly what they need
query {
  user(id: 123) {
    id
    displayName  # New field
    # email not requested = no breaking change when removed
  }
}

Deprecation is built into the schema:

1
2
3
4
5
6
type User {
  id: ID!
  displayName: String!
  name: String @deprecated(reason: "Use displayName instead")
  email: String @deprecated(reason: "Removed for privacy. Use contactEmail on Profile.")
}

The trade-off: more complex tooling, potential for expensive queries.

The Versioning Checklist

Before making a breaking change:

  1. Can you make it backward-compatible? (Usually yes)
  2. Have you documented the change? (Migration guide)
  3. Have you set a deprecation period? (Minimum 6 months for public APIs)
  4. Have you added sunset headers? (RFC 8594)
  5. Are you tracking deprecated endpoint usage? (Know who to notify)
  6. Is the new version tested against old clients? (Compatibility matrix)

Breaking changes are sometimes necessary. Breaking trust never is.

Version thoughtfully. Deprecate loudly. Sunset gracefully.