Backwards Compatibility¶
Make changes without breaking existing clients.
The Golden Rule¶
Existing clients should continue working after API updates.
If the final implementation cannot preserve a public contract, its commit/PR header must include ! regardless of type. Classify this from the actual final diff and use the authoritative commit and version contract; a BREAKING CHANGE: footer alone is not a major-version signal.
Usually Compatible Changes¶
Compatibility depends on actual consumers, not only whether a schema change is additive. Verify generated clients, strict decoders, exhaustive switches, validators, and persisted payload readers before deciding that ! is unnecessary.
Adding Optional Fields¶
# Before
class User(BaseModel):
id: int
name: str
# After - compatible only when response consumers tolerate unknown fields
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 - compatible when it does not alter existing routing or behavior
@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 - compatible when omitted parameters preserve existing behavior
@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 - compatible only when consumers handle unknown values
class Status(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived" # New value
Adding an enum value is incompatible for consumers that exhaustively switch over known values or reject unknown values. An optional request field is generally compatible because existing callers may omit it; an added response field is compatible only when consumers tolerate extra properties. Classify the real consumer contract rather than assuming every additive change is safe.
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 | Verify consumers tolerate optional or unknown fields |
| 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 | Release notes and migration guidance |
| Version major breaks | New URL version if unavoidable |