Back to Blog

API Versioning System Design

Real-World Problem Context

Your payment API has been running in production for two years. 200 partner integrations depend on it. The product team wants you to change the response format: rename amount to total_amount, nest payment details inside a payment object, and drop three deprecated fields. If you just ship the change, every partner's integration breaks overnight. If you never change the API, you accumulate technical debt forever.

API versioning lets you evolve your API without breaking existing consumers. But the choice of versioning strategy (URL path, query parameter, header, content negotiation) has long-term consequences for routing, caching, documentation, and maintenance burden.


Problem Statement

APIs are contracts between your service and its consumers. Once consumers depend on a response shape, you can't change it without breaking them. But APIs must evolve — new features, better naming, security fixes, performance improvements.

The core challenge: how do you introduce breaking changes to an API while giving existing consumers time to migrate — without maintaining an unsustainable number of parallel versions?


Potential Solutions

1. URL Path Versioning

The version is part of the URL:

GET /api/v1/products/123
GET /api/v2/products/123
from fastapi import FastAPI, APIRouter

app = FastAPI()

# Version 1 routes
v1 = APIRouter(prefix="/api/v1")

@v1.get("/products/{product_id}")
async def get_product_v1(product_id: str):
    product = fetch_product(product_id)
    return {
        "id": product.id,
        "name": product.name,
        "amount": product.price,          # Old field name
        "currency": product.currency,
        "in_stock": product.in_stock,
        "deprecated_field": "value",       # Still returned in v1
    }

# Version 2 routes
v2 = APIRouter(prefix="/api/v2")

@v2.get("/products/{product_id}")
async def get_product_v2(product_id: str):
    product = fetch_product(product_id)
    return {
        "id": product.id,
        "name": product.name,
        "pricing": {                       # Nested object (breaking change)
            "total_amount": product.price,  # Renamed field
            "currency": product.currency,
        },
        "availability": {
            "in_stock": product.in_stock,
            "quantity": product.quantity,   # New field in v2
        },
    }

app.include_router(v1)
app.include_router(v2)

2. Header Versioning

Version specified in a custom header:

@app.get("/api/products/{product_id}")
async def get_product(product_id: str, request: Request):
    version = request.headers.get("X-API-Version", "1")
    product = fetch_product(product_id)
    
    if version == "2":
        return format_v2(product)
    else:
        return format_v1(product)

# Client sends:
# GET /api/products/123
# X-API-Version: 2
#
# Clean URLs but version is invisible in the URL.
# Can't share a versioned link — version is in the header.

3. Query Parameter Versioning

GET /api/products/123?version=2
@app.get("/api/products/{product_id}")
async def get_product(product_id: str, version: int = 1):
    product = fetch_product(product_id)
    
    formatters = {1: format_v1, 2: format_v2}
    formatter = formatters.get(version, format_v1)
    return formatter(product)

4. Content Negotiation (Accept Header)

Use the standard HTTP Accept header with a vendor media type:

@app.get("/api/products/{product_id}")
async def get_product(product_id: str, request: Request):
    accept = request.headers.get("accept", "application/vnd.myapi.v1+json")
    
    if "vnd.myapi.v2" in accept:
        return format_v2(fetch_product(product_id))
    else:
        return format_v1(fetch_product(product_id))

# Client sends:
# Accept: application/vnd.myapi.v2+json
#
# Most RESTful approach but also the least intuitive for consumers.
# GitHub API uses this approach.

5. Additive Changes Only (No Versioning)

Avoid breaking changes entirely by only making additive changes:

# Instead of renaming or removing fields, ADD new fields:

# Original response:
{"id": "123", "amount": 10.99, "currency": "USD"}

# Evolved response (backward compatible):
{
    "id": "123",
    "amount": 10.99,           # Keep old field (deprecated but present)
    "currency": "USD",
    "pricing": {               # Add new structure
        "total_amount": 10.99,
        "currency": "USD",
    }
}

# Old clients ignore "pricing" — they still read "amount".
# New clients read "pricing" — they ignore "amount".
# No version bump needed. Both clients work simultaneously.

Trade-offs & Considerations

Strategy          Discoverability   Caching     Simplicity   RESTful?   Best For
─────────────────────────────────────────────────────────────────────────────────
URL path (/v1/)   Very high         Easy        Simple       Debatable  Public APIs
Header            Low               Complex     Medium       Yes        Internal APIs
Query param       High              Complex     Simple       No         Quick iteration
Content negotiation Medium          Complex     Complex      Most       GitHub-style
No versioning     N/A               Easy        Simplest     Yes        Well-designed APIs

The maintenance cost of versions:

1 version:  1 set of handlers, tests, docs.
2 versions: 2 sets. Some shared logic, some diverged.
3 versions: Combinatorial complexity. Bug fix in shared logic
            must be tested against all 3 versions.

After 5 versions, you're maintaining 5 parallel APIs.
Each version needs tests, documentation, monitoring.
Rule of thumb: support at most 2-3 active versions.
Deprecate aggressively with clear timelines.

Best Practices

  1. Prefer additive changes over new versions — adding a field is backward-compatible. Renaming, removing, or restructuring fields requires a new version. Design APIs with evolution in mind from day one.

  2. Use URL path versioning for public APIs — it's the most visible and intuitive approach. Stripe, Twilio, and most public APIs use it. Easy to test, share, and cache.

  3. Support at most N-1 and N — maintain only the current and previous version. Give consumers 6-12 months to migrate. Then sunset the old version.

  4. Deprecation headers on old versions — return Sunset: Sat, 01 Jan 2026 00:00:00 GMT and Deprecation: true headers so clients can detect and plan migration.

  5. Share business logic, version only the serialization — the handler for /v1/products and /v2/products should call the same service layer. Only the response formatting differs.

  6. Version the entire API, not individual endpoints/api/v2/ means the entire v2 API. Don't have /api/v1/products and /api/v2/orders — that's confusing.


Step-by-Step Approach

Step 1: Determine if you actually need a new version
  ├── Adding a field? → No version needed (additive change)
  ├── Adding an optional parameter? → No version needed
  ├── Renaming a field? → Version needed (or keep both)
  ├── Removing a field? → Version needed
  └── Changing a field's type? → Version needed

Step 2: Design the new version
  ├── Define the new response shape
  ├── Share business logic with the old version
  └── Only serialize differently in the response layer

Step 3: Implement side-by-side
  ├── Add new versioned routes
  ├── Old routes continue to work identically
  └── Test both versions with full test suites

Step 4: Communicate the migration
  ├── Add deprecation headers to v1 responses
  ├── Document migration guide (field mapping, new features)
  ├── Announce timeline: "v1 sunset in 12 months"
  └── Notify high-usage consumers directly

Step 5: Monitor adoption
  ├── Track request volume per version
  ├── Identify consumers still on v1
  ├── Reach out to slow migrators
  └── Extend deadline only if significant usage remains

Step 6: Sunset the old version
  ├── Return 410 Gone (not 404) after sunset date
  ├── Include migration URL in error response
  └── Remove v1 code after 30-day grace period

Conclusion

The best API versioning strategy is to avoid versioning as long as possible by making only additive, backward-compatible changes. When breaking changes are unavoidable, URL path versioning (/api/v1/, /api/v2/) is the most practical choice for public APIs — it's visible, cacheable, and simple. Keep versions minimal (max 2-3 active), share business logic between versions (only the serialization layer should differ), and deprecate aggressively with clear timelines and sunset headers. The real cost of API versioning isn't the routing — it's maintaining parallel test suites, documentation, and monitoring for every active version.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.