Die Oberfläche ist lokalisiert; Skill-Beschreibungen und -Dokumente werden per KI automatisch in Ihre Sprache übersetzt. Code, Befehle, Pfade und Fachbegriffe bleiben im Original, maßgeblich ist das Dokument des Autors.
Skills entdecken
Produktivität · SKILL.md

api-versioning-strategies

api-versioning-strategies

Implements API versioning strategies (URL path, Accept header, query parameter, media type) to manage backward compatibility, deprecation timelines, and migration paths while maintaining stable contracts for consumers.

Independent GitHub snapshot · Automated classification · Original documentation governs

Skill-Dokumentation

GitHub

name: api-versioning-strategies description: Implements API versioning strategies (URL path, Accept header, query parameter, media type) to manage backward compatibility, deprecation timelines, and migration paths while maintaining stable contracts for consumers. license: MIT compatibility: opencode metadata: version: "1.0.0" domain: coding triggers: api versioning, url path versioning, accept header, breaking changes, api deprecation, sunset header, backward compatibility, api migration api deprecation archetypes:

  • tactical
  • generation anti_triggers:
  • brainstorming
  • vague ideation
  • code golf
  • over-engineering response_profile: verbosity: low directive_strength: high abstraction_level: operational role: implementation scope: implementation output-format: code related-skills: api-design, input-validation, code-review, security-review

API Versioning Strategies

Manages the full API versioning lifecycle — choosing a versioning strategy, classifying breaking vs. compatible changes, deprecating old versions gracefully, and guiding consumers through migrations with zero downtime.

TL;DR Checklist

  • Choose URL path versioning for public APIs (most discoverable); Accept header for internal/B2B APIs
  • Classify every change as BREAKING or COMPATIBLE before release
  • Always include Deprecation and Sunset headers when serving deprecated versions
  • Support dual-version operation during migration windows (minimum 90 days)
  • Never remove a field from responses — mark it deprecated, keep returning it for the sunset period
  • Never make an optional field required in an existing endpoint

When to Use

Use this skill when:

  • Designing versioning strategy for a new public or internal API
  • Planning to introduce breaking changes to an existing API
  • Deprecating old API versions and migrating consumers
  • Classifying whether a proposed API change is breaking or compatible
  • Building migration guides for API consumers
  • Auditing an existing API for proper versioning and deprecation practices

When NOT to Use

Avoid this skill for:

  • Internal microservice communication (use gRPC schema evolution instead)
  • GraphQL APIs (they have their own evolution model — see coding-graphql-schema-design)
  • UI/frontend versioning (separate concern from API contracts)

Core Workflow

  1. Select Versioning Strategy — Match the strategy to your audience and constraints. URL path for public discoverability; Accept header for clean URLs and B2B clients; media type negotiation when you already use content negotiation. Checkpoint: The chosen strategy must support all consumers without requiring SDK changes for compatible updates.
  2. Classify the Change — Determine whether each proposed change is BREAKING or COMPATIBLE using the classification rules below. Checkpoint: All breaking changes MUST go into a new version; compatible changes MAY be added to any version.
  3. Implement Versioned Endpoints — Code the version-specific logic with typed signatures, docstrings, and consistent error handling. Checkpoint: Each version returns exactly what its contract promises — nothing more, nothing less.
  4. Add Deprecation Signals — Serve deprecated versions with Deprecation: true and Sunset headers. Provide migration guidance in response bodies. Checkpoint: Clients receive at least 90 days' notice before a version is retired.
  5. Support Migration — Run both old and new versions simultaneously during the migration window. Track adoption via analytics. Checkpoint: No consumer is broken by the sunset date.

Implementation Patterns / Reference Guide

Strategy 1: URL Path Versioning (/v1/, /v2/)

Most common for public APIs. The version is part of the route, making it explicit, cacheable, and trivially discoverable.

How it works: Every endpoint is prefixed with a version segment. GET /api/v1/users and GET /api/v2/users are entirely separate routes.

Pros:

  • Trivially discoverable — clients see the version in the URL
  • Perfect cache isolation per version (CDN caches don't collide)
  • No client SDK changes needed for compatible updates within a version
  • Easy to A/B test different versions
  • Every HTTP method, header, and query parameter works naturally

Cons:

  • Version exposed in every URL (some consider it ugly)
  • Route duplication if versions share logic
  • Harder to enforce "only one version active"

When to use: Public APIs where discoverability matters most. Used by Stripe (/v1/customers), Google Cloud, many REST APIs.

Real-world example — Stripe:

GET https://api.stripe.com/v1/customers
GET https://api.stripe.com/v1/charges
POST https://api.stripe.com/v1/payment_intents
from typing import Any
from fastapi import FastAPI, APIRouter, Request
from pydantic import BaseModel, Field


# --- V1: Legacy flat response structure ---
class UserResponseV1(BaseModel):
    """Legacy user model — flat structure returned by /v1 endpoints."""
    id: str
    name: str
    email: str
    created_at: str  # ISO 8601 string


# --- V2: Structured response with metadata (JSON:API inspired) ---
class UserAttributes(BaseModel):
    """Structured user attributes used in V2 responses."""
    name: str = Field(..., description="Full display name")
    email: str = Field(..., description="Primary email address")
    created_at: str = Field(..., description="Account creation timestamp in ISO 8601")


class UserLinks(BaseModel):
    """Hypermedia links for a user resource."""
    self: str = Field(..., description="Link to this resource")
    orders: str | None = Field(None, description="Link to user's order history")


class UserData(BaseModel):
    """V2 user response envelope with data and metadata."""
    data: UserAttributes
    links: UserLinks


app = FastAPI(title="Versioned API Example")

router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
router_v2 = APIRouter(prefix="/api/v2", tags=["v2"])


@router_v1.get("/users/{user_id}", response_model=UserResponseV1)
async def get_user_v1(user_id: str) -> UserResponseV1:
    """Retrieve a user by ID using the legacy flat structure.

    This endpoint returns a flat JSON object for backward compatibility
    with clients that expect the V1 response shape.

    Args:
        user_id: The unique identifier of the user.

    Returns:
        UserResponseV1 with id, name, email, created_at fields.

    Raises:
        HTTPException 404 if user not found.
    """
    # Simulated database lookup
    user = {"id": user_id, "name": "Alice Smith", "email": "alice@example.com", "created_at": "2024-01-15T08:30:00Z"}
    if not user:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")
    return UserResponseV1(**user)


@router_v2.get("/users/{user_id}", response_model=UserData)
async def get_user_v2(user_id: str, request: Request) -> UserData:
    """Retrieve a user by ID using the structured V2 envelope.

    V2 wraps the response in a data/metadata envelope with hypermedia
    links, following JSON:API conventions for better discoverability.

    Args:
        user_id: The unique identifier of the user.
        request: FastAPI request object for generating self-link URLs.

    Returns:
        UserData with attributes (name, email, created_at) and links.

    Raises:
        HTTPException 404 if user not found.
    """
    from fastapi import HTTPException
    base_url = str(request.base_url).rstrip("/")
    user = {"id": user_id, "name": "Alice Smith", "email": "alice@example.com", "created_at": "2024-01-15T08:30:00Z"}
    if not user:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")

    return UserData(
        data=UserAttributes(
            name=user["name"],
            email=user["email"],
            created_at=user["created_at"],
        ),
        links=UserLinks(
            self=f"{base_url}/api/v2/users/{user_id}",
            orders=f"{base_url}/api/v2/users/{user_id}/orders",
        ),
    )


app.include_router(router_v1)
app.include_router(router_v2)

Strategy 2: Accept Header Versioning (Accept: application/vnd.myapi.v2+json)

Uses the HTTP Accept header to negotiate which version of the response format to return. The URL stays clean and versionless.

How it works: The client specifies the desired API version in the Accept header. The server routes to the appropriate handler based on the vendor-specific media type.

Pros:

  • Clean, stable URLs — no /v1/ or /v2/ prefixes
  • Content negotiation is a well-understood HTTP mechanism (RFC 7231)
  • Same endpoint can return different representations

Cons:

  • Harder to debug — version not visible in the URL
  • Caching complications: Vary: Accept means the CDN must cache multiple variants per URL
  • Less discoverable — developers can't guess the endpoint structure from a browser visit
  • Not all HTTP clients/proxies handle vendor media types well

When to use: Internal APIs, B2B partnerships where clean URLs matter, or when you already do content negotiation. Used by GitHub (Accept: application/vnd.github.v3+json).

Real-world example — GitHub:

# GitHub uses Accept header with date-based versioning
curl -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/users/octocat
# Response includes: x-github-api-version-selected: 2022-11-28
from typing import Any
from fastapi import FastAPI, APIRouter, Request, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel


app = FastAPI(title="Accept Header Versioning Example")


class UserV1(BaseModel):
    """Legacy flat user representation for Accept: application/vnd.myapi.v1+json."""
    id: str
    name: str
    email: str


class UserV2(BaseModel):
    """Structured user representation with metadata for Accept: application/vnd.myapi.v2+json."""
    data: dict[str, Any]
    meta: dict[str, str] = {
        "api_version": "2.0",
        "documentation": "https://api.example.com/docs/v2",
    }


SUPPORTED_VERSIONS = {
    "application/vnd.myapi.v1+json": UserV1,
    "application/vnd.myapi.v2+json": UserV2,
}

router_users = APIRouter(tags=["users"])


@router_users.get("/users/{user_id}")
async def get_user_with_accept_version(
    user_id: str,
    accept_header: str = Header(..., alias="Accept"),
):
    """Route to the correct version based on Accept header content negotiation.

    Clients specify the desired API version via the Accept header using
    vendor-specific media types. The server returns the matching schema.

    Supported versions:
        - application/vnd.myapi.v1+json (legacy flat structure)
        - application/vnd.myapi.v2+json (structured with metadata)

    Args:
        user_id: The unique identifier of the user.
        accept_header: The Accept header value from the request.

    Returns:
        JSONResponse with the matched version's schema.
    """
    # Simulated user data
    user_data = {
        "id": user_id,
        "name": "Alice Smith",
        "email": "alice@example.com",
        "created_at": "2024-01-15T08:30:00Z",
    }

    # Match Accept header to supported version
    for media_type, model in SUPPORTED_VERSIONS.items():
        if media_type in accept_header or "application/json" in accept_header and media_type == list(SUPPORTED_VERSIONS.keys())[-1]:
            if issubclass(model, UserV2):
                return JSONResponse(
                    content={
                        "data": user_data,
                        "meta": {"api_version": "2.0", "documentation": "https://api.example.com/docs/v2"},
                    }
                )
            else:
                return JSONResponse(
                    content={"id": user_data["id"], "name": user_data["name"], "email": user_data["email"]}
                )

    # No matching version — fall back to v1 as default
    return JSONResponse(
        content={
            "error": "Unsupported media type. Supported: application/vnd.myapi.v1+json, application/vnd.myapi.v2+json",
            "supported_versions": list(SUPPORTED_VERSIONS.keys()),
        },
        status_code=406,
    )


app.include_router(router_users)

Strategy 3: Query Parameter Versioning (?version=1)

The version is passed as a query parameter. Simple but considered the least elegant approach.

How it works: GET /api/users?version=1 vs GET /api/users?version=2. A single route handles all versions.

Pros:

  • Extremely simple to implement — one route, version branching inside the handler
  • No URL path duplication
  • Easy to test different versions in a browser

Cons:

  • Pollutes query parameters
  • Same Vary problems as Accept header versioning for caching
  • Not RESTful — version is not a resource attribute
  • Harder to document (Swagger/OpenAPI struggles with dynamic routes)
  • Most widely criticized approach by API designers

When to use: Internal tools, quick prototypes, or when you absolutely cannot change URLs. Rarely recommended for production public APIs.

Strategy 4: Media Type / Content Negotiation (Accept: application/json;version=1)

Embeds version information in the media type itself, without vendor-specific subtypes.

How it works: Accept: application/json; version=1 or Accept: application/vnd.api+json;version=1. Similar to Accept header versioning but uses the standard media type parameters.

Pros:

  • Uses standard HTTP content negotiation (RFC 7231)
  • Cleaner than query params
  • Compatible with existing caching infrastructure if Vary is set properly

Cons:

  • Media type parameters are rarely used in practice — most tools don't support them well
  • OpenAPI/Swagger documentation struggles with media type parameters
  • Harder to test and debug in browsers

When to use: When you want standard HTTP semantics and your tooling supports media type parameters. Less common than vendor-specific subtypes.

Strategy 5: Date-Based Versioning (Stripe/GitHub approach)

Uses a date string as the version identifier rather than a number. Stripe-Version: 2024-12-18.acacia or X-GitHub-Api-Version: 2022-11-28.

How it works: The API endpoint itself is not versioned. Instead, the client sends a date-based header indicating which API "snapshot" to use. The server maps dates to API implementations internally.

Pros:

  • URLs are completely clean — no /v1/, no vendor subtypes in Accept
  • Backwards compatible by default — old dates keep working forever
  • Natural migration path: upgrade the date header when you're ready
  • Version is human-readable (you know exactly which era of the API it represents)

Cons:

  • Requires SDK/client-side changes to update the version header
  • Less discoverable — consumers must read docs to know which version string to use
  • Harder to communicate "what changed between dates" without good changelogs
  • The server must maintain backward compatibility indefinitely for old date headers

When to use: Mature, high-volume APIs where URL cleanliness matters and you have SDK support. Used by Stripe (Stripe-Version header) and GitHub (X-GitHub-Api-Version header).

Real-world example — Stripe (2024-2025):

# Stripe uses date-based versioning via custom header
curl -u sk_test_...: \
  -H "Stripe-Version: 2024-12-18.acacia" \
  https://api.stripe.com/v1/customers

# The /v1/ path is stable (never changes); the Stripe-Version header
# controls which feature set is active. This means the URL never changes,
# only the header does when you want new features.

Real-world example — GitHub:

# GitHub uses date-based versioning via X-GitHub-Api-Version header
curl -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/users/octocat

# Response confirms which version was selected:
# x-github-api-version-selected: 2022-11-28

Breaking vs. Non-Breaking Changes

Classification Rules

A breaking change is any modification that causes existing clients to fail — either by returning unexpected data shapes, changing semantics, or removing functionality. A compatible (non-breaking) change is one that existing clients will continue to process correctly without modification.

Breaking Changes (MUST go into a new version)

ChangeWhy It BreaksExample
Removing an endpointClients calling the URL get 404Deleting /api/v1/legacy/reports
Removing a response fieldParsers that read .data.field get undefined / KeyErrorRemoving email from user response
Renaming a fieldSame as removing + adding — old names return nulluser_name → name in response JSON
Changing a field typeType coercion may fail or lose dataage: int → age: string
Making an optional field requiredExisting requests without the field get 422Adding phone_number: required to POST /users
Changing error codes/messages (for coded errors)Clients matching on error.code breakINVALID_EMAIL → VALIDATION_EMAIL_INVALID
Removing an enum valueClients sending that value get 422Removing "archived" from status enum
Changing the base URL or domainDNS/client config breaks entirelyMoving from api.example.com to v2-api.example.com
Changing HTTP method semanticsPUT clients calling POST endpoints failConverting GET /users/{id} to DELETE

Compatible Changes (MAY be added to any version)

ChangeWhy It's SafeExample
Adding a new endpointExisting routes unaffectedAdding /api/v1/teams when only /api/v1/users exists
Adding an optional request parameterExisting requests without the param still workAdding ?include_metadata=false to list endpoints
Adding a response fieldParsers that ignore unknown fields are fine (JSON spec)Adding avatar_url to user response
Adding an enum valueExisting clients don't send the new valueAdding "suspended" to status enum
Relaxing validation rulesStricter→looser is always safeAccepting both "us" and "USA" for country codes
Changing default sort orderClients explicitly sorting are unaffectedDefault from alphabetical → by creation date
Adding pagination parametersExisting requests without pagination still return full resultsAdding ?limit=100&cursor=... to list endpoints
Returning more data in a response arrayArray consumers iterate over all itemsAdding new fields to each item in /users list response

Classification Decision Tree

Is the change to an existing endpoint's response shape?
├── YES → Is it adding a field (not required)?
│         ├── YES → COMPATIBLE ✓
│         └── NO  → Is it removing or renaming a field?
│                   ├── YES → BREAKING ✗
│                   └── NO  → Is it changing a field type?
│                             ├── YES → BREAKING ✗
│                             └── NO  → COMPATIBLE ✓

Is the change to an existing endpoint's request handling?
├── YES → Is it adding an optional parameter?
│         ├── YES → COMPATIBLE ✓
│         └── NO  → Is it making a required field?
│                   ├── YES → BREAKING ✗
│                   └── NO  → Is it removing validation constraints?
│                             ├── YES → COMPATIBLE ✓ (stricter→looser)
│                             └── NO  → BREAKING ✗

Is the change adding a new endpoint?
└── YES → COMPATIBLE ✓

Real-World Examples of Breaking Changes

# ❌ BREAKING — Removing a field from response
class UserResponseOLD(BaseModel):
    id: str
    name: str
    email: str          # ← REMOVED in V2

# Existing client code breaks:
#   user["email"]  → KeyError / undefined


# ✅ COMPATIBLE — Adding a new field to response
class UserResponseNew(BaseModel):
    id: str
    name: str
    email: str
    phone: str | None = None  # ← NEW optional field

# Existing client code still works — it just ignores the new field.


# ❌ BREAKING — Changing a field type
class OrderV1(BaseModel):
    total_cents: int        # ← integer, e.g., 1999

class OrderV2(BaseModel):
    total_cents: str         # ← STRING, e.g., "1999"

# Existing client that does: price = order["total_cents"] * 2
#   → TypeError: can't multiply sequence by int


# ✅ COMPATIBLE — Adding an optional request parameter
@router.post("/users")
async def create_user(
    body: CreateUserRequest,     # existing required fields
    notify_admin: bool = False,   # ← NEW optional parameter
):
    """Adding this parameter doesn't break any existing client."""
    ...

Deprecation and Migration Patterns

Standard HTTP Headers for Deprecation

The HTTP specification defines three headers specifically for API deprecation. Every deprecated endpoint should include these in its responses:

HeaderPurposeExample Value
DeprecationSignals the resource is deprecatedtrue or a timestamp
SunsetDate after which the version will be unavailableSat, 01 Jun 2027 00:00:00 GMT
LinkLink to migration documentation or successor version<https://docs.example.com/migration/v1-to-v2>; rel="successor-version"
from typing import Any
from datetime import datetime, timezone


def add_deprecation_headers(
    response: Any,
    sunset_date: datetime = datetime(2027, 6, 1, tzinfo=timezone.utc),
    migration_url: str = "https://docs.example.com/migration/v1-to-v2",
) -> None:
    """Attach standard deprecation headers to an API response.

    These headers inform HTTP clients that the served version is deprecated
    and provide a sunset date and migration guidance. Standard HTTP clients
    (curl, Postman, SDKs) should surface these warnings to developers.

    Args:
        response: The FastAPI Response object to modify in-place.
        sunset_date: The date after which this API version will return 410 Gone.
        migration_url: URL to the migration guide for consumers.
    """
    from fastapi import Response as FastAPIResponse
    if isinstance(response, FastAPIResponse):
        response.headers["Deprecation"] = "true"
        response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
        response.headers["Link"] = f'<{migration_url}>; rel="successor-version"'


# Usage in a FastAPI middleware or endpoint
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

DEPRECATION_CONFIG = {
    "sunset_date": datetime(2027, 6, 1, tzinfo=timezone.utc),
    "migration_url": "https://docs.example.com/migration/v1-to-v2",
}


class DeprecationMiddleware(BaseHTTPMiddleware):
    """Automatically adds deprecation headers to v1 API responses."""

    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)

        if "/api/v1/" in request.url.path:
            add_deprecation_headers(
                response,
                sunset_date=DEPRECATION_CONFIG["sunset_date"],
                migration_url=DEPRECATION_CONFIG["migration_url"],
            )

        return response


app = FastAPI()
app.add_middleware(DeprecationMiddleware)

Sunset Timeline Best Practices

PhaseTimelineActionClient Behavior
AnnouncementDay 0Add Deprecation and Sunset headers; publish changelogClients see warnings in response headers
Grace periodDays 1–90Serve both old and new versions; log version usageClients have time to migrate without pressure
Hard warningDay +60Add Warning: 999 header with deprecation messageClients get HTTP Warning header (RFC 7234)
SunsetDay +90Return 410 Gone for deprecated endpoints; remove from docsClients must migrate or update SDKs
from datetime import datetime, timezone


def check_sunset_status(
    sunset_date: datetime = datetime(2027, 6, 1, tzinfo=timezone.utc),
) -> dict[str, Any]:
    """Determine the current deprecation phase based on sunset date.

    Returns a status dict that can be used to conditionally add headers
    or modify responses based on how close the sunset date is.

    Args:
        sunset_date: The date after which the API version will return 410 Gone.

    Returns:
        Dict with 'phase', 'days_remaining', 'warning_header' keys.
    """
    now = datetime.now(timezone.utc)
    days_remaining = (sunset_date - now).days

    if days_remaining > 90:
        return {
            "phase": "deprecation_announced",
            "days_remaining": days_remaining,
            "warning_header": None,
            "headers": {"Deprecation": "true"},
        }
    elif days_remaining > 30:
        return {
            "phase": "migration_window",
            "days_remaining": days_remaining,
            "warning_header": f"999; url=\"https://docs.example.com/migration\"; msg=\"API v1 deprecated; sunset in {days_remaining} days\"",
            "headers": {"Deprecation": "true", "Warning": f"999 sunset-{sunset_date.strftime('%Y%m%d')} \"API v1 will be unavailable after {sunset_date.strftime('%b %d, %Y')}\""},
        }
    elif days_remaining > 0:
        return {
            "phase": "final_warning",
            "days_remaining": days_remaining,
            "warning_header": f"999; url=\"https://docs.example.com/migration\"; msg=\"URGENT: API v1 sunsetting in {days_remaining} days\"",
            "headers": {"Deprecation": "true", "Warning": f"999 sunset-{sunset_date.strftime('%Y%m%d')} \"CRITICAL: API v1 will be unavailable after {sunset_date.strftime('%b %d, %Y')}\""},
        }
    else:
        return {
            "phase": "sunsetting",
            "days_remaining": days_remaining,
            "warning_header": None,
            "headers": {},  # Return 410 Gone instead
        }


# Usage in endpoint to conditionally respond
async def get_user_v1_with_sunset(user_id: str):
    """V1 endpoint with dynamic sunset behavior."""
    status = check_sunset_status()

    if status["phase"] == "sunsetting":
        from fastapi import HTTPException
        raise HTTPException(
            status_code=410,
            detail={
                "error": {
                    "code": "API_VERSION_SUNSET",
                    "message": f"API v1 is no longer available. Please migrate to v2.",
                    "migration_url": "https://docs.example.com/migration/v1-to-v2",
                    "sunset_date": "2027-06-01T00:00:00Z",
                }
            },
        )

    # ... return user data with deprecation headers ...
    response = JSONResponse(content={"id": user_id, "name": "Alice"})
    add_deprecation_headers(response)
    if status.get("warning_header"):
        response.headers["Warning"] = status["warning_header"]
    return response

Dual-Version Support During Migration

Run both versions simultaneously. This is the only safe migration path for public APIs.

from typing import Any
from fastapi import FastAPI, APIRouter, Request
from pydantic import BaseModel


app = FastAPI(title="Dual-Version Migration Example")


class CreateUserRequest(BaseModel):
    """Shared request model — same contract across versions."""
    name: str
    email: str


class UserResponseV1(BaseModel):
    id: str
    name: str
    email: str
    created_at: str


class UserResponseV2(BaseModel):
    data: dict[str, Any]
    metadata: dict[str, str] = {"api_version": "2.0"}


router_v1 = APIRouter(prefix="/api/v1")
router_v2 = APIRouter(prefix="/api/v2")

# Shared business logic — both versions call the same service layer
async def create_user_service(data: CreateUserRequest) -> dict[str, Any]:
    """Core user creation logic shared across all API versions.

    This isolates business logic from version-specific response formatting.
    Both V1 and V2 routes call this function and format the result differently.

    Args:
        data: Validated create user request.

    Returns:
        Dictionary with id, name, email, created_at fields.
    """
    # Database insert would go here
    return {
        "id": "usr_abc123",
        "name": data.name,
        "email": data.email,
        "created_at": "2024-06-15T10:00:00Z",
    }


@router_v1.post("/users", response_model=UserResponseV1)
async def create_user_v1(body: CreateUserRequest) -> UserResponseV1:
    """V1: Returns flat response structure for backward compatibility."""
    user = await create_user_service(body)
    return UserResponseV1(**user)


@router_v2.post("/users", response_model=UserResponseV2)
async def create_user_v2(body: CreateUserRequest, request: Request) -> UserResponseV2:
    """V2: Returns structured envelope with metadata."""
    user = await create_user_service(body)
    return UserResponseV2(
        data=user,
        metadata={
            "api_version": "2.0",
            "documentation": str(request.base_url).rstrip("/") + "/docs",
            "migration_guide": "https://docs.example.com/migration/v1-to-v2",
        },
    )


app.include_router(router_v1)
app.include_router(router_v2)

Migration Guide Template

Every deprecated version must have a corresponding migration guide.

# Migration Guide: API v1 → v2

## What Changed

### Response Structure
- V1 returns flat objects; V2 wraps in `data` envelope with `metadata`
- All dates now use ISO 8601 format consistently (`2024-01-15T08:30:00Z`)

### Breaking Changes
- **None** — this migration is fully compatible at the data level
- The only change is response envelope shape; field values are identical

## Code Migration

### Before (v1)
```python
import requests
response = requests.get("https://api.example.com/api/v1/users/abc123")
user = response.json()  # {"id": "abc123", "name": "Alice", ...}
print(user["email"])

After (v2)

import requests
response = requests.get("https://api.example.com/api/v2/users/abc123")
user = response.json()  # {"data": {...}, "metadata": {...}}
print(user["data"]["email"])

Timeline

DateEvent
2025-06-01v1 deprecated — Deprecation: true header added
2026-12-01v1 sunset — returns 410 Gone

Need Help?


---

## BAD vs GOOD Examples

### ❌ BAD — No versioning, breaking changes deployed directly

```python
# ❌ BAD: Modifying existing endpoints without versioning
@app.route("/api/users/<user_id>", methods=["GET"])
def get_user_bad(user_id):
    """This endpoint works fine until you change the response format."""

    # Developer decides to add a new field...
    user = User.get(user_id)
    return jsonify({
        "id": user.id,
        "name": user.name,
        "email": user.email,
        "avatar_url": user.avatar.url,  # ← NEW: existing clients don't expect this
        "profile": {                     # ← NEW NESTED OBJECT: breaks flat parsers
            "bio": user.bio,
            "location": user.location,
        }
    })

# Developer later decides to rename a field...
    return jsonify({
        "user_id": user.id,      # ← RENAMED from "id" — existing clients break
        "full_name": user.name,  # ← RENAMED from "name" — existing clients break
        "email_address": user.email,  # ← RENAMED from "email" — existing clients break
    })

# Developer removes a field...
    return jsonify({
        "user_id": user.id,
        "full_name": user.name,
        # "email_address" removed — clients that read this get undefined/KeyError
    })

✅ GOOD — Versioned endpoints with clean separation

# ✅ GOOD: Each version has its own route and response model
from fastapi import FastAPI, APIRouter
from pydantic import BaseModel


app = FastAPI()


class UserFlat(BaseModel):
    """V1 response model — flat structure. Stable contract, never changes."""
    id: str
    name: str
    email: str


class UserNested(BaseModel):
    """V2 response model — structured with nested profile data."""
    id: str
    attributes: dict[str, str]
    links: dict[str, str]

    class Config:
        json_schema_extra = {
            "example": {
                "id": "usr_123",
                "attributes": {"name": "Alice", "email": "alice@example.com"},
                "links": {"self": "/api/v2/users/usr_123", "profile": "/api/v2/users/usr_123/profile"},
            }
        }


v1_router = APIRouter(prefix="/api/v1")
v2_router = APIRouter(prefix="/api/v2")


@v1_router.get("/users/{user_id}", response_model=UserFlat)
async def get_user_v1(user_id: str):
    """V1 endpoint — flat structure. Never add/remove/renamed fields here."""
    user = User.get(user_id)
    return UserFlat(id=user.id, name=user.name, email=user.email)


@v2_router.get("/users/{user_id}", response_model=UserNested)
async def get_user_v2(user_id: str):
    """V2 endpoint — structured with nested data and hypermedia links."""
    user = User.get(user_id)
    return UserNested(
        id=user.id,
        attributes={"name": user.name, "email": user.email},
        links={
            "self": f"/api/v2/users/{user_id}",
            "profile": f"/api/v2/users/{user_id}/profile",
        },
    )


app.include_router(v1_router)
app.include_router(v2_router)

❌ BAD — Deprecation without migration support

# ❌ BAD: Adding a deprecation header but no sunset date or migration path
@app.after_request
def bad_deprecation(response):
    """Just adding 'Deprecation: true' is not enough."""
    if "/api/v1/" in request.path:
        response.headers["Deprecation"] = "true"
        # ← No Sunset header — clients don't know when it goes away
        # ← No Link to migration docs — consumers are stranded
    return response

✅ GOOD — Complete deprecation with actionable headers

# ✅ GOOD: Full deprecation signal with sunset date and migration link
from datetime import datetime, timezone


@app.after_request
def good_deprecation(response):
    """Complete deprecation signal per HTTP best practices."""
    if "/api/v1/" in request.path:
        response.headers["Deprecation"] = "true"

        # Sunset date: give consumers 18 months to migrate
        sunset = datetime(2027, 6, 1, tzinfo=timezone.utc)
        response.headers["Sunset"] = sunset.strftime("%a, %d %b %Y %H:%M:%S GMT")

        # Link header pointing to migration documentation
        response.headers["Link"] = (
            '<https://docs.example.com/migration/v1-to-v2>; '
            'rel="successor-version"'
        )

        # Warning header for HTTP clients that parse RFC 7234 warnings
        days_left = (sunset - datetime.now(timezone.utc)).days
        response.headers["Warning"] = (
            f'999 sunset-20270601 "API v1 will be unavailable after June 1, 2027"'
        )

    return response

Industry Practices (2024–2025)

Stripe — Date-Based Versioning + Stable URLs

Stripe uses URL path (/v1/) for the stable API surface combined with a Stripe-Version header for feature control. The /v1/ prefix never changes; only the date-based version header controls which features are active.

# The /v1/ path is permanent. Only the Stripe-Version header changes.
curl -H "Stripe-Version: 2024-12-18.acacia" \
  https://api.stripe.com/v1/customers

# Stripe's approach means:
# 1. URL structure never breaks (stable /v1/ prefix)
# 2. New features are opt-in via the version header
# 3. Old feature sets remain available indefinitely
# 4. SDKs manage version headers automatically

GitHub — Accept Header with Date-Based Versions

GitHub uses X-GitHub-Api-Version header with date strings for API versioning. The response includes x-github-api-version-selected to confirm which version was active.

# Request with explicit API version
curl -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/owner/repo

# Response confirms the selected version
# x-github-api-version-selected: 2022-11-28

GitHub also uses Deprecation and Sunset headers for deprecated endpoints:

curl -I "https://api.github.com/repos/owner/repo/commits" 2>&1 | grep -iE "deprecat|sunset|warning"
# Deprecation: true
# Sunset: Sat, 05 Jul 2019 00:00:00 GMT
# Link: <https://docs.github.com/overview/changelog>; rel="successor-version"

Twilio — Date-Based URL Versioning

Twilio embeds the date directly in the URL path. The API base is https://api.twilio.com/{API_VERSION}/ where {API_VERSION} is a date like 2010-04-01.

# Twilio's version IS part of the URL path
curl -u ACxxxx:auth_token \
  https://api.twilio.com/2010-04-01/Accounts/ACxxx/Messages.json

# This means the API_VERSION segment can be changed to migrate
# Old versions remain available but deprecated; new dates provide new features

General Industry Consensus (2024–2025)

  1. URL path versioning remains the most common for public REST APIs — it's the most discoverable and simplest to reason about
  2. Date-based versioning is growing among mature APIs (Stripe, GitHub, Twilio) where SDKs handle version selection
  3. Accept header versioning is standard in enterprise/B2B APIs where clean URLs matter
  4. Query parameter versioning is increasingly avoided — most API designers consider it the least professional approach
  5. 90-day minimum sunset periods are now standard; many mature APIs offer 180–365 days
  6. No breaking changes after v1 is the dominant philosophy — most stable public APIs treat their initial version as "final" and add rather than change

Constraints

MUST DO

  • Classify every API change as BREAKING or COMPATIBLE before release — never assume
  • Use URL path versioning (/api/vN/) for public-facing APIs; Accept header for B2B/internal
  • Include Deprecation: true, Sunset (RFC 1123 date), and Link headers on all deprecated endpoints
  • Support at least two active versions simultaneously during migration windows
  • Maintain a minimum 90-day sunset notice period before retiring any version
  • Write OpenAPI/Swagger documentation for each API version separately — never maintain docs per-version in one file
  • Use typed request/response models (Pydantic, zod, TypeScript interfaces) — never return untyped dicts
  • Return consistent error envelopes across all versions: {"error": {"code": "...", "message": "..."}}
  • Document breaking changes in a public changelog before each new version release
  • Test deprecated endpoints through their entire sunset period — monitor that no consumer is broken on the sunset date

MUST NOT DO

  • Remove response fields without keeping them (nullable) until the version reaches 410 Gone
  • Make optional request parameters required in an existing version
  • Change enum values to different meanings — add new values, deprecate old ones
  • Use query parameter versioning for production public APIs
  • Deploy breaking changes to a currently-documented API version without creating a new version
  • Set sunset dates less than 90 days out — this breaks consumer migration plans
  • Return different error formats between versions — keep the error envelope consistent
  • Remove deprecated endpoints before their Sunset date has passed — return 410 Gone instead
  • Share response model classes between versions that have different fields — each version needs its own schema

Output Template

When implementing or reviewing API versioning, produce:

  1. Versioning Strategy Recommendation — Chosen approach with rationale (URL path / Accept header / date-based), compared against alternatives
  2. Change Classification Matrix — Table of proposed changes classified as BREAKING or COMPATIBLE with rationale
  3. Versioned Endpoint Specifications — HTTP method, path, request/response schemas, and status codes for each version
  4. Deprecation Plan — Sunset timeline, header signals, and migration guide links
  5. Migration Guide — Before/after code examples, SDK changes required, data mapping notes
  6. Cache Strategy — How caching (CDN, browser, proxy) differs per version with Vary header recommendations

Related Skills

SkillPurpose
api-designGeneral REST API design patterns — resource modeling, pagination, rate limiting
input-validationRequest validation strategies for typed APIs (Pydantic, zod, JSON Schema)
code-reviewReview versioned API implementations for correctness and backward compatibility
security-reviewAudit versioned endpoints for authZ/authN consistency across versions

References

Gespeicherte Skills