API Versioning and Evolution
Manage API changes over time without breaking clients
APIs never stay static. You add features, fix bugs, improve performance, and refactor code. But every change risks breaking existing clients, mobile apps that can't update immediately, third-party integrations, internal services running old versions.
API versioning solves this problem. It allows you to introduce changes without forcing all clients to upgrade simultaneously. Stripe maintains v1 APIs from 2011 alongside modern versions. Twitter's API v1.1 coexisted with v2 for years during migration.
In this lesson, you'll understand breaking vs non-breaking changes, implement versioning strategies (URI, header, content negotiation), follow semantic versioning rules, manage deprecation timelines, use feature flags and blue-green deployments, write migration guides, and decide when to sunset old versions.
Lesson Sections
Why API Versioning Matters
Every API change has consequences. Some changes are safe (adding new fields), others break existing clients (removing fields, changing types). Without versioning, you're forced to choose: break all clients or never improve your API.
Breaking vs Non-Breaking Changes
Non-Breaking Changes (Safe)
- Add new endpoint: GET /v1/users/stats
- Add optional field: {"age": 25} (clients ignore unknown fields)
- Add new query parameter: ?include=metadata (optional)
- Add new response field: Existing parsers ignore it
- Make required → optional: Less restrictive
- Relax validation: Accept more input formats
Breaking Changes (Dangerous)
- Remove endpoint: DELETE /v1/users
- Remove field: No more "email" in response
- Rename field: "username" → "user_name"
- Change type: "id": "123" → "id": 123
- Make optional → required: Must provide "age" now
- Change semantics: Same field, different meaning
Real-World Impact of Breaking Changes
Let's see what happens when you make a breaking change without versioning:
# Version 1.0 (original API)
GET /api/users/123
{
"id": 123,
"username": "alice",
"email": "alice@example.com",
"created_at": "2020-01-15"
}
# Mobile app released in January 2020
# Expects "email" field, uses it for password reset# Version 2.0 (breaking change - June 2020)
# You decide "email" should be "email_address" for clarity
GET /api/users/123
{
"id": 123,
"username": "alice",
"email_address": "alice@example.com", # ⚠️ Renamed field
"created_at": "2020-01-15"
}
# Result: All mobile apps crash!
# Error: KeyError: 'email'
# Users can't reset passwords
# App Store rating drops from 4.5★ to 2.1★
# 10,000 support tickets in 24 hoursThe Cost of Breaking Changes
- Mobile apps: Users don't update immediately (30-day update rate ~60%)
- Third-party integrations: Partners need months to migrate
- Legacy systems: Some clients can't be updated (embedded devices)
- Trust damage: Breaking APIs damages developer relationships
- Support burden: Handling migration support and bug reports
The Solution: Versioning
With versioning, you can introduce breaking changes while keeping old clients working:
# Version 1 - Keep running for existing clients
GET /api/v1/users/123
{
"id": 123,
"username": "alice",
"email": "alice@example.com",
"created_at": "2020-01-15"
}
# Version 2 - New clients use improved structure
GET /api/v2/users/123
{
"id": 123,
"username": "alice",
"email_address": "alice@example.com",
"created_at": "2020-01-15T10:30:00Z", # ISO 8601 format
"profile": {
"bio": "Software engineer",
"avatar": "https://..."
}
}
# Result:
# ✓ Old mobile apps keep working
# ✓ New clients get improved API
# ✓ Smooth migration over 6-12 months
# ✓ Happy users, happy developersKey Insight
Versioning isn't about maintaining old code forever. It's about giving clients time to migrate gracefully. Typically, you maintain 2-3 versions simultaneously, announce deprecation 6-12 months ahead, and sunset old versions when usage drops below 5%.
Versioning Strategies
There are four main ways to version APIs: URI versioning, header versioning, query parameter versioning, and content negotiation. Each has trade-offs in simplicity, HTTP compliance, caching, and developer experience.
1. URI Versioning (Most Common)
Version is part of the URL path. This is the most popular approach, used by Stripe, Twitter, GitHub, and most public APIs.
# Examples GET https://api.stripe.com/v1/charges GET https://api.twitter.com/2/tweets GET https://api.example.com/v1/users # Patterns /v1/users # Major version only /v2.1/users # Major.minor version /2023-10-01/users # Date-based (Stripe uses this)
Pros
- Extremely clear and visible
- Easy to test (just change URL)
- Works with HTTP caching
- Browser-friendly (can bookmark)
Cons
- Not RESTful (different URLs for same resource)
- URL duplication (/v1/users, /v2/users)
- Versioning becomes part of API structure
2. Header Versioning
Version is specified in custom HTTP header. Keeps URLs clean but less discoverable.
# Request with custom header
GET https://api.example.com/users
API-Version: 2
Accept: application/json
# Response
HTTP/1.1 200 OK
API-Version: 2
{
"users": [...]
}
# GitHub uses this approach
GET https://api.github.com/users/octocat
X-GitHub-Api-Version: 2022-11-28Pros
- RESTful (same URL for same resource)
- Clean URLs
- Version in metadata, not resource path
Cons
- Hidden (not visible in URL)
- Harder to test in browser
- Complicates HTTP caching
- Requires documentation reading
3. Query Parameter Versioning
Version is a query parameter. Simple but can clutter URLs with other parameters.
# Examples GET https://api.example.com/users?version=2 GET https://api.example.com/users?api_version=2.1 GET https://api.example.com/users?v=2 # Can mix with other parameters GET https://api.example.com/users?version=2&limit=10&offset=20
Pros
- Easy to implement
- Visible in URL
- Optional (can have default version)
Cons
- Clutters URLs with parameters
- Not a common pattern
- Versioning mixed with filtering/pagination
4. Content Negotiation (Accept Header)
Version specified in Accept header's media type. Most RESTful but least common.
# Request
GET https://api.example.com/users
Accept: application/vnd.example.v2+json
# Response
HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
{
"users": [...]
}
# GitHub API example
GET https://api.github.com/users/octocat
Accept: application/vnd.github.v3+jsonPros
- True HTTP content negotiation
- Most RESTful approach
- Clean URLs
- Follows HTTP spec perfectly
Cons
- Most complex to implement
- Hidden version
- Hard to test in browser
- Uncommon, surprising to developers
Comparison: Which Strategy to Choose?
| Strategy | Example | Best For | Used By |
|---|---|---|---|
| URI Versioning | /v1/users | Public APIs, developer-facing | Stripe, Twitter, Google |
| Header Versioning | API-Version: 2 | Internal APIs, when URLs must stay clean | GitHub (also), Azure |
| Query Parameter | ?version=2 | Simple APIs, optional versioning | Netflix (legacy) |
| Content Negotiation | Accept: vnd.api.v2+json | Strictly RESTful APIs | Rare in practice |
Most Common: URI Versioning
URI versioning (/v1/, /v2/) is the most widely used approach. It's simple, discoverable, easy to test, and familiar to developers. Companies like Stripe, Twitter, and GitHub use this pattern because it provides excellent developer experience, even though it's not strictly RESTful.
Recommended: Content Negotiation
Content Negotiation is the most RESTful and architecturally correct approach. It uses proper HTTP semantics, keeps URLs clean, and follows the principle that the same resource should have the same URL regardless of representation. While more complex to implement and less common in practice, it's the best choice for truly RESTful APIs where architectural purity and HTTP compliance matter.
Semantic Versioning for APIs
Semantic Versioning (SemVer) provides clear rules for when to bump version numbers. While originally designed for software libraries (check it here), it adapts well to APIs with some modifications.
Version Number Format: MAJOR.MINOR.PATCH
# Semantic Version Format v2.3.1 │ │ │ │ │ └─ PATCH: Bug fixes, no API changes │ └─── MINOR: New features, backward compatible └───── MAJOR: Breaking changes, not backward compatible # Examples v1.0.0 → v1.0.1 Bug fix (no breaking changes) v1.0.1 → v1.1.0 Added new endpoint (backward compatible) v1.1.0 → v2.0.0 Removed field (breaking change)
When to Increment Each Version Level
PATCH Version (v1.0.0 → v1.0.1)
Bug fixes only. No API contract changes.
# PATCH changes examples: # ✓ Fixed incorrect calculation # Before: total = subtotal # Bug: missing tax # After: total = subtotal + tax # ✓ Fixed typo in error message # Before: "User not fond" # After: "User not found" # ✓ Performance improvement # Same response, just faster (200ms → 50ms) # ✓ Internal refactoring # Code restructured, but API contract unchanged # ✓ Fixed status code # Before: 200 OK with error message (wrong) # After: 400 Bad Request with error message (correct)
MINOR Version (v1.0.0 → v1.1.0)
New features added in backward-compatible way.
# MINOR changes examples:
# ✓ New endpoint
GET /v1/users/123/stats # New, doesn't affect existing endpoints
# ✓ New optional field in request
POST /v1/users
{
"username": "alice",
"email": "alice@example.com",
"bio": "Developer" # New optional field
}
# ✓ New field in response
GET /v1/users/123
{
"id": 123,
"username": "alice",
"email": "alice@example.com",
"created_at": "2020-01-15",
"last_login": "2024-01-15" # New field (clients ignore unknown fields)
}
# ✓ New query parameter (optional)
GET /v1/users?include_metadata=true # Optional, default behavior unchanged
# ✓ Make required field optional
# Before: "email" required
# After: "email" optional (less restrictive, backward compatible)MAJOR Version (v1.0.0 → v2.0.0)
Breaking changes that are not backward compatible.
# MAJOR changes examples:
# ✗ Remove endpoint
DELETE /v1/users # Removed entirely
# ✗ Remove field
GET /v1/users/123
{
"id": 123,
"username": "alice"
# "email" removed - breaks clients expecting it
}
# ✗ Rename field
{
"user_name": "alice" # Was "username", now "user_name"
}
# ✗ Change field type
{
"id": 123 # Was string "123", now integer 123
}
# ✗ Make optional field required
POST /v1/users
{
"username": "alice",
"email": "alice@example.com",
"phone": "+1234567890" # Now required, was optional
}
# ✗ Change response structure
# Before: {"users": [...]}
# After: {"data": {"users": [...]}} # Wrapped differently
# ✗ Change authentication method
# Before: API key in query parameter
# After: Bearer token in Authorization headerAPI-Specific Semantic Versioning Rules
| Change Type | Version Bump | Example |
|---|---|---|
| Add new endpoint | MINOR | POST /v1/users/bulk-create |
| Add optional request field | MINOR | {"age": 25} (optional) |
| Add response field | MINOR | {"avatar": "..."} |
| Add new query parameter | MINOR | ?sort=created_at |
| Remove endpoint | MAJOR | DELETE /v1/users (removed) |
| Remove field | MAJOR | "email" no longer returned |
| Rename field | MAJOR | "username" → "user_name" |
| Change type | MAJOR | "id": "123" → "id": 123 |
| Make field required | MAJOR | "phone" now required |
| Make field optional | MINOR | "bio" now optional |
| Bug fix | PATCH | Fix incorrect calculation |
| Performance improvement | PATCH | Same response, faster |
Practical Tip: Most APIs Only Expose MAJOR Versions
While internal versioning may be v2.3.1, public APIs typically only expose MAJOR versions in URLs (/v1/, /v2/). MINOR and PATCH changes happen transparently without requiring URL changes. Only increment the URL version for breaking changes. Track full semantic version in response headers: X-API-Version: 2.3.1
Backward Compatibility
Backward compatibility is your commitment to existing clients. It means new API versions won't break clients using older versions. This requires careful change management and clear deprecation processes.
The Deprecation Process
When you need to remove or change something, follow a structured deprecation process:
Typical Deprecation Timeline
Announce
Email, blog post, changelog
Warning
Add deprecation headers
Reminder
Email users still on old version
Sunset
Remove old version
Using the Sunset Header (RFC 8594)
The Sunset HTTP header communicates when an API version will be retired.
# Response from deprecated endpoint
HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/v2/users>; rel="successor-version"
Warning: 299 - "API v1 will be sunset on 2024-12-31. Please migrate to v2."
{
"users": [...]
}Python implementation with FastAPI:
from fastapi import FastAPI, Response
from datetime import datetime
app = FastAPI()
@app.get("/v1/users")
async def get_users_v1(response: Response):
"""
Deprecated endpoint - use v2 instead.
Sunset date: 2024-12-31
"""
# Add deprecation headers
response.headers["Sunset"] = "Sat, 31 Dec 2024 23:59:59 GMT"
response.headers["Deprecation"] = "true"
response.headers["Link"] = '<https://api.example.com/v2/users>; rel="successor-version"'
response.headers["Warning"] = '299 - "API v1 will be sunset on 2024-12-31. Please migrate to v2."'
# Return data (still works, but deprecated)
return {"users": [...]}
@app.get("/v2/users")
async def get_users_v2():
"""Current version - recommended"""
return {"users": [...]}Graceful Degradation Strategy
Support old clients while encouraging migration:
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
# Define supported versions
SUPPORTED_VERSIONS = ["1", "2"]
DEFAULT_VERSION = "2"
DEPRECATED_VERSIONS = ["1"]
def get_api_version(api_version: str = Header(default=DEFAULT_VERSION, alias="API-Version")) -> str:
"""
Extract and validate API version from header.
Adds deprecation warnings for old versions.
"""
if api_version not in SUPPORTED_VERSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {api_version}. Supported versions: {SUPPORTED_VERSIONS}"
)
return api_version
@app.get("/users")
async def get_users(
response: Response,
version: str = Depends(get_api_version)
):
"""
Versioned endpoint - behavior changes based on API-Version header.
"""
# Warn if using deprecated version
if version in DEPRECATED_VERSIONS:
response.headers["Sunset"] = "Sat, 31 Dec 2024 23:59:59 GMT"
response.headers["Deprecation"] = "true"
# Return version-specific response
if version == "1":
return {
"users": [
{"id": 1, "name": "Alice", "email": "alice@example.com"}
]
}
elif version == "2":
return {
"data": {
"users": [
{"id": 1, "username": "alice", "email_address": "alice@example.com"}
]
},
"meta": {"version": "2", "count": 1}
}Never Break Without Warning
Minimum deprecation period: 6 months for public APIs, 3 months for internal APIs.Monitor version usage before sunsetting. If 10%+ of traffic still uses old version, extend the deadline or provide migration assistance. Breaking without warning destroys trust permanently.
Implementation with FastAPI
FastAPI makes versioning clean and maintainable using APIRouter to organize versions into separate modules. Here's how to structure a versioned API.
Project Structure for Versioned API
project/ ├── app/ │ ├── main.py # FastAPI app entry point │ ├── v1/ │ │ ├── __init__.py │ │ ├── routes.py # v1 endpoints │ │ └── schemas.py # v1 Pydantic models │ ├── v2/ │ │ ├── __init__.py │ │ ├── routes.py # v2 endpoints │ │ └── schemas.py # v2 Pydantic models │ └── shared/ │ ├── database.py # Shared database logic │ └── auth.py # Shared authentication └── tests/ ├── test_v1.py └── test_v2.py
Step 1: Define Version 1 (Original)
# app/v1/schemas.py from pydantic import BaseModel class UserV1(BaseModel): id: int name: str email: str class CreateUserV1(BaseModel): name: str email: str
# app/v1/routes.py
from fastapi import APIRouter, HTTPException
from .schemas import UserV1, CreateUserV1
router = APIRouter(prefix="/v1", tags=["v1"])
# In-memory database (shared across versions)
from app.shared.database import users_db
@router.get("/users/{user_id}", response_model=UserV1)
async def get_user_v1(user_id: int):
"""Get user by ID - v1 format"""
user = users_db.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserV1(
id=user["id"],
name=user["username"], # Map username → name
email=user["email"]
)
@router.post("/users", response_model=UserV1)
async def create_user_v1(user: CreateUserV1):
"""Create user - v1 format"""
new_id = max(users_db.keys(), default=0) + 1
new_user = {
"id": new_id,
"username": user.name, # Map name → username in db
"email": user.email
}
users_db[new_id] = new_user
return UserV1(
id=new_user["id"],
name=new_user["username"],
email=new_user["email"]
)Step 2: Define Version 2 (Improved)
# app/v2/schemas.py from pydantic import BaseModel from typing import Optional class UserV2(BaseModel): id: int username: str # Changed: name → username email_address: str # Changed: email → email_address bio: Optional[str] = None # New field class CreateUserV2(BaseModel): username: str email_address: str bio: Optional[str] = None
# app/v2/routes.py
from fastapi import APIRouter, HTTPException
from .schemas import UserV2, CreateUserV2
router = APIRouter(prefix="/v2", tags=["v2"])
from app.shared.database import users_db
@router.get("/users/{user_id}", response_model=UserV2)
async def get_user_v2(user_id: int):
"""Get user by ID - v2 format"""
user = users_db.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserV2(
id=user["id"],
username=user["username"],
email_address=user["email"],
bio=user.get("bio")
)
@router.post("/users", response_model=UserV2)
async def create_user_v2(user: CreateUserV2):
"""Create user - v2 format"""
new_id = max(users_db.keys(), default=0) + 1
new_user = {
"id": new_id,
"username": user.username,
"email": user.email_address,
"bio": user.bio
}
users_db[new_id] = new_user
return UserV2(
id=new_user["id"],
username=new_user["username"],
email_address=new_user["email"],
bio=new_user.get("bio")
)Step 3: Main Application Setup
# app/main.py
from fastapi import FastAPI
from app.v1 import routes as v1_routes
from app.v2 import routes as v2_routes
app = FastAPI(
title="My Versioned API",
description="API with v1 and v2 versions",
version="2.0.0"
)
# Include version routers
app.include_router(v1_routes.router)
app.include_router(v2_routes.router)
@app.get("/")
async def root():
return {
"message": "Welcome to My API",
"versions": {
"v1": {
"status": "deprecated",
"sunset_date": "2024-12-31",
"docs": "/docs#/v1"
},
"v2": {
"status": "stable",
"docs": "/docs#/v2"
}
}
}Testing Both Versions
# V1 request
curl http://localhost:8000/v1/users/1
# V1 response
{
"id": 1,
"name": "alice",
"email": "alice@example.com"
}
# V2 request
curl http://localhost:8000/v2/users/1
# V2 response
{
"id": 1,
"username": "alice",
"email_address": "alice@example.com",
"bio": null
}Result: Clean Separation
- ✓ Each version in separate module (v1/, v2/)
- ✓ Shared code in shared/ (database, auth)
- ✓ Clear URL structure (/v1/users, /v2/users)
- ✓ Separate Pydantic schemas per version
- ✓ Both versions work simultaneously
Production Best Practices
Versioning in production requires discipline. Beyond choosing a strategy and writing code, you need monitoring, documentation, and clear communication with API consumers to manage versions effectively at scale.
Version Monitoring and Analytics
Track which versions are in use, by whom, and how much traffic each version receives. This data drives deprecation decisions.
# app/middleware/version_tracking.py
from fastapi import Request
from collections import defaultdict
import logging
logger = logging.getLogger(__name__)
# Track version usage per client
version_metrics = defaultdict(lambda: defaultdict(int))
async def version_tracking_middleware(request: Request, call_next):
"""Track API version usage for deprecation decisions."""
# Extract version from URL path
path = request.url.path
version = "unknown"
if "/v1/" in path:
version = "v1"
elif "/v2/" in path:
version = "v2"
# Track client (API key or IP)
client_id = request.headers.get("X-API-Key", request.client.host)
# Increment counter
version_metrics[version][client_id] += 1
# Log deprecated version usage
if version == "v1":
logger.warning(
f"Deprecated v1 API called by {client_id}: {request.method} {path}"
)
response = await call_next(request)
# Add version info to response headers
response.headers["X-API-Version"] = version
response.headers["X-API-Deprecated"] = str(version == "v1").lower()
return response
# Register middleware
# app.middleware("http")(version_tracking_middleware)Changelog and Migration Guides
Every version change needs clear documentation. Publish a changelog with every release and provide step-by-step migration guides for breaking changes.
# Example: API Changelog
## v2.0.0 (2024-06-01) - Breaking Changes
### Breaking
- RENAMED: "name" field → "username" in User responses
- RENAMED: "email" field → "email_address" in User responses
- CHANGED: Response wrapper from {"users": [...]} to {"data": {"users": [...]}}
### Added
- NEW FIELD: "bio" (optional) in User responses
- NEW ENDPOINT: GET /v2/users/{id}/activity
### Migration Guide
1. Update field references:
- response.name → response.username
- response.email → response.email_address
2. Update response parsing:
- response.users → response.data.users
3. Timeline:
- v1 deprecated: 2024-06-01
- v1 sunset: 2024-12-31
- Migration support: support@api.example.comProduction Checklist
API Versioning Checklist
- Choose a strategy: URI versioning for public APIs, header versioning for internal APIs
- Document all changes: Maintain a changelog with every version release
- Use Sunset headers: Communicate deprecation dates in HTTP responses (RFC 8594)
- Monitor version usage: Track traffic per version and per client before sunsetting
- Provide migration guides: Step-by-step instructions for upgrading between versions
- Test all versions: Maintain separate test suites for each supported version
- Set deprecation timelines: Minimum 6 months for public APIs, 3 months for internal
- Communicate proactively: Email, blog posts, and in-response warnings before sunset
- Share code where possible: Use shared modules for database, auth, and business logic across versions
Key Takeaways
- Non-breaking changes are safe, adding fields, endpoints, or optional parameters won't break existing clients
- Breaking changes need versioning, removing fields, renaming fields, changing types, or making optional fields required all require a new major version
- URI versioning (/v1/, /v2/) is the most common, simple, discoverable, and used by Stripe, Twitter, and most public APIs
- Content negotiation is the most RESTful, uses proper HTTP semantics with Accept headers, preferred when architectural purity matters
- Follow semantic versioning, MAJOR for breaking changes, MINOR for new features, PATCH for bug fixes
- Deprecate before removing, use Sunset headers (RFC 8594), provide 6-12 months notice, and monitor usage before sunsetting
- Separate versions in code, use FastAPI's APIRouter to organize each version into its own module with shared business logic
- Monitor version traffic, track which clients use which versions to make informed deprecation decisions