Skip to content

Backwards Compatibility

Make changes without breaking existing clients.

The Golden Rule

Existing clients should continue working after API updates.

Safe Changes (Non-Breaking)

These changes are backwards compatible:

Adding Optional Fields

# Before
class User(BaseModel):
    id: int
    name: str

# After - safe
class User(BaseModel):
    id: int
    name: str
    avatar_url: Optional[str] = None  # New optional field
    bio: Optional[str] = None         # New optional field

Adding New Endpoints

# Existing endpoints unchanged
@app.get("/users")
async def list_users():
    pass

# New endpoint - safe
@app.get("/users/{user_id}/activity")
async def get_user_activity(user_id: int):
    pass

Adding Optional Parameters

# Before
@app.get("/posts")
async def list_posts(status: Optional[str] = None):
    pass

# After - safe
@app.get("/posts")
async def list_posts(
    status: Optional[str] = None,
    author_id: Optional[int] = None,  # New optional param
    sort: str = "created_at",         # New param with default
):
    pass

Adding Enum Values

# Before
class Status(str, Enum):
    DRAFT = "draft"
    PUBLISHED = "published"

# After - safe (clients ignore unknown values)
class Status(str, Enum):
    DRAFT = "draft"
    PUBLISHED = "published"
    ARCHIVED = "archived"  # New value

Breaking Changes (Avoid)

These changes break existing clients:

Removing Fields

# Before
class User(BaseModel):
    id: int
    name: str
    legacy_field: str

# After - BREAKING!
class User(BaseModel):
    id: int
    name: str
    # legacy_field removed - clients expecting it will fail

Solution: Deprecate First

class User(BaseModel):
    id: int
    name: str
    legacy_field: Optional[str] = Field(
        None,
        deprecated=True,
        description="Deprecated. Use 'new_field' instead.",
    )
    new_field: Optional[str] = None

Renaming Fields

# Before
class User(BaseModel):
    name: str

# After - BREAKING!
class User(BaseModel):
    full_name: str  # Renamed - clients using 'name' break

Solution: Keep Both

class User(BaseModel):
    full_name: str
    name: Optional[str] = Field(
        None,
        deprecated=True,
        description="Deprecated. Use 'full_name' instead.",
    )

    @model_validator(mode="before")
    @classmethod
    def set_name(cls, values):
        # Mirror full_name to name for backwards compatibility
        if not values.get("name"):
            values["name"] = values.get("full_name")
        return values

Changing Field Types

# Before
class User(BaseModel):
    age: int

# After - BREAKING!
class User(BaseModel):
    age: str  # Type changed - clients expecting int break

Solution: Add New Field

class User(BaseModel):
    age: int  # Keep as int
    age_range: Optional[str] = None  # Add new field for string representation

Making Optional Fields Required

# Before
class UserCreate(BaseModel):
    name: str
    email: Optional[str] = None

# After - BREAKING!
class UserCreate(BaseModel):
    name: str
    email: str  # Now required - clients not sending email break

Changing URL Structure

# Before
@app.get("/users/{user_id}")

# After - BREAKING!
@app.get("/accounts/{user_id}")  # URL changed

Solution: Add Redirect

# Keep old endpoint with redirect
@app.get("/users/{user_id}", deprecated=True)
async def get_user_old(user_id: int):
    return RedirectResponse(f"/accounts/{user_id}", status_code=301)

# New endpoint
@app.get("/accounts/{user_id}")
async def get_user(user_id: int):
    pass

Deprecation Process

1. Announce Deprecation

@app.get(
    "/v1/legacy-endpoint",
    deprecated=True,
    description="**Deprecated**: Use `/v2/new-endpoint` instead. Will be removed 2024-06-01.",
)
async def legacy_endpoint():
    pass

2. Add Warning Headers

@app.middleware("http")
async def deprecation_headers(request: Request, call_next):
    response = await call_next(request)

    # Check if deprecated endpoint
    if is_deprecated(request.url.path):
        response.headers["Deprecation"] = "true"
        response.headers["Sunset"] = "Sat, 01 Jun 2024 00:00:00 GMT"
        response.headers["Link"] = '</v2/new-endpoint>; rel="successor-version"'

    return response

3. Log Usage

import structlog

log = structlog.get_logger()

DEPRECATED_ENDPOINTS = {
    "/v1/legacy": {"successor": "/v2/new", "sunset": "2024-06-01"},
}

@app.middleware("http")
async def track_deprecated_usage(request: Request, call_next):
    path = request.url.path

    if path in DEPRECATED_ENDPOINTS:
        log.warning(
            "deprecated_endpoint_used",
            path=path,
            successor=DEPRECATED_ENDPOINTS[path]["successor"],
            sunset=DEPRECATED_ENDPOINTS[path]["sunset"],
            client_id=get_client_id(request),
        )

    return await call_next(request)

4. Notify Clients

# Send notifications to API consumers
async def notify_deprecation():
    for client in await get_api_clients():
        if client.uses_deprecated_endpoints:
            await send_email(
                to=client.email,
                subject="API Deprecation Notice",
                body=f"""
                The following endpoints you're using will be removed on 2024-06-01:

                - /v1/legacy → Use /v2/new instead

                Please update your integration before the sunset date.
                """,
            )

5. Sunset Period

from datetime import datetime

SUNSET_DATE = datetime(2024, 6, 1)

@app.get("/v1/legacy")
async def legacy_endpoint():
    if datetime.utcnow() >= SUNSET_DATE:
        raise HTTPException(
            status_code=410,  # Gone
            detail={
                "error": "endpoint_retired",
                "message": "This endpoint has been removed",
                "successor": "/v2/new",
                "documentation": "https://docs.example.com/migration",
            },
        )

    # Still functional during sunset period
    return await handle_legacy_request()

Field Evolution Patterns

Adding Fields Safely

# Version 1
class UserV1(BaseModel):
    id: int
    name: str

# Version 1.1 - Add optional field
class UserV1_1(BaseModel):
    id: int
    name: str
    avatar: Optional[str] = None  # Clients can ignore

# Version 1.2 - Add another optional field
class UserV1_2(BaseModel):
    id: int
    name: str
    avatar: Optional[str] = None
    settings: Optional[dict] = None

Changing Field Structure

# Before: Flat address
class UserV1(BaseModel):
    name: str
    address_street: str
    address_city: str
    address_zip: str

# After: Nested address (keep both for compatibility)
class Address(BaseModel):
    street: str
    city: str
    zip: str

class UserV2(BaseModel):
    name: str
    address: Address

    # Backwards compatibility
    @property
    def address_street(self) -> str:
        return self.address.street

    @property
    def address_city(self) -> str:
        return self.address.city

    @property
    def address_zip(self) -> str:
        return self.address.zip

    @staticmethod
    def _json_schema_extra(schema, model):
        schema["properties"]["address_street"] = {"type": "string"}
        schema["properties"]["address_city"] = {"type": "string"}
        schema["properties"]["address_zip"] = {"type": "string"}

    model_config = ConfigDict(json_schema_extra=_json_schema_extra)

Nullable to Non-Nullable

# Before: Optional field
class Post(BaseModel):
    title: str
    category: Optional[str] = None

# After: Required field with default on read
class Post(BaseModel):
    title: str
    category: str = "general"  # Default for old data

# Migration: Backfill existing data
async def migrate_posts():
    await db.execute(
        update(Post).where(Post.category == None).values(category="general")
    )

Request Compatibility

Accept Multiple Formats

from pydantic import BaseModel, model_validator
from typing import Union

class CreateUserRequest(BaseModel):
    # Accept both old and new field names
    name: Optional[str] = None
    full_name: Optional[str] = None

    @model_validator(mode="before")
    @classmethod
    def merge_name_fields(cls, values):
        # Use full_name if provided, otherwise fall back to name
        if not values.get("full_name"):
            values["full_name"] = values.get("name")
        if not values.get("name") and not values.get("full_name"):
            raise ValueError("Either 'name' or 'full_name' is required")
        return values

Flexible Input Parsing

from pydantic import BaseModel, field_validator
from typing import Union, List

class TagsInput(BaseModel):
    # Accept string or list of strings
    tags: Union[str, List[str]]

    @field_validator("tags", mode="before")
    @classmethod
    def normalize_tags(cls, v):
        if isinstance(v, str):
            return [t.strip() for t in v.split(",")]
        return v

Testing Backwards Compatibility

import pytest
from httpx import AsyncClient

# Store sample responses from previous versions
V1_USER_RESPONSE = {
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com",
}

class TestBackwardsCompatibility:
    async def test_v1_fields_still_present(self, client: AsyncClient):
        """Ensure v1 response fields are still returned."""
        response = await client.get("/users/1")
        data = response.json()

        # All v1 fields must still exist
        for key in V1_USER_RESPONSE:
            assert key in data, f"Missing v1 field: {key}"

    async def test_v1_request_still_works(self, client: AsyncClient):
        """Ensure v1 request format still works."""
        # Old request format
        response = await client.post(
            "/users",
            json={"name": "Jane Doe", "email": "jane@example.com"},
        )
        assert response.status_code == 201

    async def test_deprecated_fields_return_values(self, client: AsyncClient):
        """Deprecated fields should still return data."""
        response = await client.get("/users/1")
        data = response.json()

        # Even if deprecated, field should have value
        assert data.get("name") is not None  # Deprecated but present

Best Practices Summary

Practice Implementation
Add, don't remove New fields are optional
Deprecate before removing 6+ month notice
Keep both field names During transition period
Use default values For new required fields
Log deprecated usage Track migration progress
Test old clients Maintain compatibility tests
Document changes Clear changelog
Version major breaks New URL version if unavoidable