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 hours
The 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 developers
Key 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-28
Pros
  • 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+json
Pros
  • 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?
StrategyExampleBest ForUsed By
URI Versioning/v1/usersPublic APIs, developer-facingStripe, Twitter, Google
Header VersioningAPI-Version: 2Internal APIs, when URLs must stay cleanGitHub (also), Azure
Query Parameter?version=2Simple APIs, optional versioningNetflix (legacy)
Content NegotiationAccept: vnd.api.v2+jsonStrictly RESTful APIsRare 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 header
API-Specific Semantic Versioning Rules
Change TypeVersion BumpExample
Add new endpointMINORPOST /v1/users/bulk-create
Add optional request fieldMINOR{"age": 25} (optional)
Add response fieldMINOR{"avatar": "..."}
Add new query parameterMINOR?sort=created_at
Remove endpointMAJORDELETE /v1/users (removed)
Remove fieldMAJOR"email" no longer returned
Rename fieldMAJOR"username" → "user_name"
Change typeMAJOR"id": "123" → "id": 123
Make field requiredMAJOR"phone" now required
Make field optionalMINOR"bio" now optional
Bug fixPATCHFix incorrect calculation
Performance improvementPATCHSame 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
Month 0

Announce
Email, blog post, changelog

Month 3

Warning
Add deprecation headers

Month 6

Reminder
Email users still on old version

Month 12

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.com
Production 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