DEV Community

ai-hustle-bro
ai-hustle-bro

Posted on

Versioned Pydantic Schemas in FastAPI: Avoiding Breaking Changes in Production

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}
Enter fullscreen mode Exit fullscreen mode

This works for one iteration. Then:

  1. Validation drift: You fix a regex bug in UserCreateV1.email but forget to update V2. Now versions validate differently.
  2. Business logic duplication: The "save logic" comment hides 50 lines of database writes, event publishing, and cache invalidation—all duplicated.
  3. No migration path: A V1 client upgrading to V2 has no guidance. You'll field support tickets asking "what changed?"
  4. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Single source of truth: Email and username validation live in UserBase. Fix once, fixed everywhere.
  • Type safety: TypeVar bound to UserBase ensures the service can't return incompatible shapes.
  • Explicit differences: Looking at UserCreateV2, you immediately see phone is new. No hunting through duplicated files.
  • OpenAPI clarity: FastAPI generates separate /v1/users and /v2/users entries in docs, each with the correct schema.

Pattern 3: Production Hardening with Deprecation Metadata

Real-world APIs need more than clean inheritance. You need:

  1. Deprecation warnings so clients know when to migrate
  2. Field-level versioning ("this field was added in V2, removed in V4")
  3. 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"
            }
        }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 User schema, you need a shared package or contract testing (Pact, Spring Cloud Contract). Inheritance doesn't span service boundaries.
  • Database migration coordination: Adding phone to UserCreateV2 means a migration for the users table. 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)