The Pain: Silent Breaking Changes in Your API Contract
You ship a FastAPI endpoint that accepts UserCreateRequest with five fields. Three months later, you add a new required field. Your mobile app—still running the old schema—starts throwing 422 Unprocessable Entity errors for 40% of users who haven't updated. You didn't version the schema, so both old and new clients hit the same endpoint, and now you're firefighting on a Friday night.
This isn't a hypothetical. According to the 2023 Postman State of the API report, 61% of developers have experienced breaking changes in production APIs. FastAPI's automatic OpenAPI generation and Pydantic's strict validation are features until you need to evolve schemas without breaking existing clients. Then they become constraints you must design around.
Why Schema Versioning Matters Now
FastAPI has become the default choice for Python APIs—62,000+ GitHub stars, sub-millisecond overhead, automatic docs. But its Pydantic integration, which validates requests at the boundary, assumes a single schema per endpoint. When your API serves mobile apps (slow release cycles), third-party integrations (you don't control deployment), or AI agents (which cache your OpenAPI spec), you must support multiple schema versions simultaneously.
The rise of AI code generation makes this worse. Tools like GitHub Copilot, Cursor, and ChatGPT generate client code from your OpenAPI spec. If you break the schema, you break every AI-generated client in the wild. Schema versioning isn't a nice-to-have anymore—it's a contract you must honor.
Three versioning strategies exist: URL path (/v1/users), header-based (API-Version: 2), and content negotiation (Accept: application/vnd.api.v2+json). Path-based versioning is the most explicit and cache-friendly, so we'll focus there. But the Pydantic patterns we build will work for any strategy.
Pattern 1: Naive Duplication (and Why It Fails)
The first instinct is to copy-paste your schemas and endpoints:
# schemas_v1.py
from pydantic import BaseModel, Field
class UserCreateV1(BaseModel):
email: str = Field(..., pattern=r'^[\w.-]+@[\w.-]+\.\w+$')
username: str = Field(..., min_length=3, max_length=20)
age: int = Field(..., ge=13)
# schemas_v2.py
class UserCreateV2(BaseModel):
email: str = Field(..., pattern=r'^[\w.-]+@[\w.-]+\.\w+$')
username: str = Field(..., min_length=3, max_length=20)
age: int = Field(..., ge=13)
phone: str = Field(..., pattern=r'^\+?1?\d{9,15}$') # new required field
# main.py
from fastapi import FastAPI
from schemas_v1 import UserCreateV1
from schemas_v2 import UserCreateV2
app = FastAPI()
@app.post("/v1/users")
async def create_user_v1(user: UserCreateV1):
# ... save logic ...
return {"id": 123, "email": user.email}
@app.post("/v2/users")
async def create_user_v2(user: UserCreateV2):
# ... save logic (duplicated) ...
return {"id": 123, "email": user.email}
This works for one iteration. Then:
-
Validation drift: You fix a regex bug in
UserCreateV1.emailbut forget to update V2. Now versions validate differently. - Business logic duplication: The "save logic" comment hides 50 lines of database writes, event publishing, and cache invalidation—all duplicated.
- No migration path: A V1 client upgrading to V2 has no guidance. You'll field support tickets asking "what changed?"
- Schema explosion: By V5, you have 25 files (5 request schemas × 5 response schemas). Dependencies between schemas make imports a nightmare.
The fundamental flaw: duplication instead of composition. Schemas are data contracts; they should inherit or compose shared rules, not copy them.
Pattern 2: Base Schemas with Versioned Extensions
Separate invariant rules (always true across versions) from version-specific additions:
# schemas/base.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional
import re
class UserBase(BaseModel):
"""Fields and validators that never change across versions."""
email: str = Field(..., max_length=255)
username: str = Field(..., min_length=3, max_length=20)
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
if not re.match(r'^[\w.+-]+@[\w.-]+\.\w+$', v):
raise ValueError('Invalid email format')
return v.lower()
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError('Username can only contain letters, numbers, hyphens, underscores')
return v
# schemas/v1.py
from schemas.base import UserBase
from pydantic import Field
class UserCreateV1(UserBase):
age: int = Field(..., ge=13, le=120)
class UserResponseV1(UserBase):
id: int
age: int
created_at: str
# schemas/v2.py
from schemas.base import UserBase
from pydantic import Field
from typing import Optional
class UserCreateV2(UserBase):
age: int = Field(..., ge=13, le=120)
phone: Optional[str] = Field(None, pattern=r'^\+?1?\d{9,15}$') # optional in V2
class UserResponseV2(UserBase):
id: int
age: int
phone: Optional[str]
created_at: str
updated_at: str
Now wire up endpoints with shared service logic:
# services/user_service.py
from schemas.base import UserBase
from typing import TypeVar, Type
import datetime
T = TypeVar('T', bound=UserBase)
class UserService:
@staticmethod
async def create_user(user_data: UserBase, response_model: Type[T]) -> T:
"""Version-agnostic create logic. Returns shape defined by response_model."""
# Simulate DB write
db_user = {
"id": 123,
"email": user_data.email,
"username": user_data.username,
"created_at": datetime.datetime.utcnow().isoformat(),
}
# Merge version-specific fields if present
if hasattr(user_data, 'age'):
db_user['age'] = user_data.age
if hasattr(user_data, 'phone'):
db_user['phone'] = user_data.phone
if 'updated_at' in response_model.model_fields:
db_user['updated_at'] = db_user['created_at']
return response_model(**db_user)
# main.py
from fastapi import FastAPI, status
from schemas.v1 import UserCreateV1, UserResponseV1
from schemas.v2 import UserCreateV2, UserResponseV2
from services.user_service import UserService
app = FastAPI()
user_service = UserService()
@app.post("/v1/users", response_model=UserResponseV1, status_code=status.HTTP_201_CREATED)
async def create_user_v1(user: UserCreateV1) -> UserResponseV1:
return await user_service.create_user(user, UserResponseV1)
@app.post("/v2/users", response_model=UserResponseV2, status_code=status.HTTP_201_CREATED)
async def create_user_v2(user: UserCreateV2) -> UserResponseV2:
return await user_service.create_user(user, UserResponseV2)
Why this works:
-
Single source of truth: Email and username validation live in
UserBase. Fix once, fixed everywhere. -
Type safety:
TypeVarbound toUserBaseensures the service can't return incompatible shapes. -
Explicit differences: Looking at
UserCreateV2, you immediately seephoneis new. No hunting through duplicated files. -
OpenAPI clarity: FastAPI generates separate
/v1/usersand/v2/usersentries in docs, each with the correct schema.
Pattern 3: Production Hardening with Deprecation Metadata
Real-world APIs need more than clean inheritance. You need:
- Deprecation warnings so clients know when to migrate
- Field-level versioning ("this field was added in V2, removed in V4")
- Validation error envelopes that include version info for debugging
Add metadata to schemas:
# schemas/versioned.py
from pydantic import BaseModel, Field
from typing import Optional, Literal
from datetime import date
class VersionMetadata(BaseModel):
version: Literal["v1", "v2", "v3"]
deprecated: bool = False
sunset_date: Optional[date] = None
migration_guide_url: Optional[str] = None
class UserCreateV1(UserBase):
age: int = Field(..., ge=13, le=120)
class Config:
json_schema_extra = {
"version_info": {
"version": "v1",
"deprecated": True,
"sunset_date": "2025-06-01",
"migration_guide_url": "https://docs.example.com/api/migration/v1-to-v2"
}
}
Add a middleware to inject deprecation headers:
# middleware/versioning.py
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Callable
class VersionDeprecationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
# Check if endpoint is versioned and deprecated
if request.url.path.startswith("/v1/"):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Sat, 01 Jun 2025 00:00:00 GMT"
response.headers["Link"] = '<https://docs.example.com/api/migration/v1-to-v2>; rel="deprecation"'
return response
# main.py
app.add_middleware(VersionDeprecationMiddleware)
Create a validation error wrapper that includes version:
# exceptions.py
from fastapi import Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
async def validation_exception_handler(request: Request, exc: RequestValidationError):
version = "unknown"
if request.url.path.startswith("/v1/"):
version = "v1"
elif request.url.path.startswith("/v2/"):
version = "v2"
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"detail": exc.errors(),
"body": exc.body,
"api_version": version,
"hint": f"This endpoint expects the {version} schema. Check https://docs.example.com/api/{version} for details."
},
)
# main.py
from fastapi.exceptions import RequestValidationError
app.add_exception_handler(RequestValidationError, validation_exception_handler)
Now when a V1 client hits your API, they get:
Deprecation: true
Sunset: Sat, 01 Jun 2025 00:00:00 GMT
Link: <https://docs.example.com/api/migration/v1-to-v2>; rel="deprecation"
And validation errors include "api_version": "v1" so your logs can segment issues by version.
What These Patterns Don't Solve
This approach handles request/response versioning within a single service. It does not cover:
-
Cross-service schema contracts: If three microservices share a
Userschema, you need a shared package or contract testing (Pact, Spring Cloud Contract). Inheritance doesn't span service boundaries. -
Database migration coordination: Adding
phonetoUserCreateV2means a migration for theuserstable. These patterns don't generate Alembic migrations or handle rollback. - Long-term version sprawl: By V8, even with inheritance, you have cognitive overhead. Consider whether old versions can be transformed to new schemas (adapters) rather than maintained forever.
- GraphQL or gRPC: These patterns assume REST + JSON. GraphQL has its own schema evolution story (deprecation directives), and Protobuf has field numbers for backward compatibility.
Build vs. Buy: The Setup Tax
You've seen the pattern: base schemas, versioned extensions, middleware for deprecation headers, custom error envelopes. Implementing this from scratch takes 4–6 hours if you're careful—defining the inheritance structure, writing validators, testing edge cases (what if a client sends V2 fields to a V1 endpoint?), and documenting the conventions for your team.
You can hand-roll it, especially if you have unique constraints (maybe you version by header, not path, or you need YAML schemas for a code generator). The architecture is sound and you now own it.
Alternatively, if you want the boilerplate done, I packaged my production setup—base schemas, three versioned examples (create, update, response), deprecation middleware, error envelopes, and 15 Pytest cases—as the FastAPI Schema Pack for $29. It's Python 3.11+, FastAPI 0.104+, Pydantic v2. Saves the setup time and gives you patterns for auth schemas and async background tasks too (not covered here). It doesn't include database models or migration scripts—you bring your own ORM.
Either way, the key takeaway is the inheritance pattern: split invariant rules into a base, extend per version, and inject version metadata into responses. That's the architecture. Whether you type it yourself or start from a template is a time-vs-money trade-off. Both are valid.
Top comments (0)