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 |