Python Response Serialization — Core Concepts

Why response serialization matters

The shape of your API responses is a contract. If you change a field name or include an internal field by accident, client applications break. Response serialization gives you explicit control over what leaves your server, ensuring consistency, security, and predictability.

The core problem

Your internal data structures rarely match what clients need. A database model might include password hashes, internal IDs, audit timestamps, and soft-delete flags. The client needs none of that. Without serialization, internal details leak into responses.

Response models in Pydantic

The standard pattern in modern Python APIs: define separate models for responses:

from pydantic import BaseModel
from datetime import datetime

# Internal — matches database
class UserDB(BaseModel):
    id: int
    name: str
    email: str
    password_hash: str
    is_deleted: bool
    created_at: datetime
    internal_flags: dict

# External — what clients see
class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    created_at: datetime

FastAPI uses the response model to both serialize the output and generate accurate OpenAPI documentation:

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    user = await db.get_user(user_id)  # Returns UserDB
    return user  # FastAPI serializes through UserResponse, stripping internal fields

Field inclusion and exclusion

Sometimes you need different views of the same data. A list endpoint might return minimal data while a detail endpoint returns full data:

class UserSummary(BaseModel):
    id: int
    name: str

class UserDetail(BaseModel):
    id: int
    name: str
    email: str
    created_at: datetime
    order_count: int

@app.get("/users", response_model=list[UserSummary])
async def list_users(): ...

@app.get("/users/{user_id}", response_model=UserDetail)
async def get_user(user_id: int): ...

Nested serialization

Real API responses have nested structures. Serialization handles these through composed models:

class AddressResponse(BaseModel):
    city: str
    country: str

class OrderItemResponse(BaseModel):
    product_name: str
    quantity: int
    unit_price_cents: int

class OrderResponse(BaseModel):
    id: int
    status: str
    items: list[OrderItemResponse]
    shipping_address: AddressResponse
    total_cents: int

Each level of nesting uses its own model, so changes to the address format only require editing AddressResponse.

Custom serializers

Some types need custom conversion logic. Pydantic v2 uses @field_serializer:

from pydantic import field_serializer

class EventResponse(BaseModel):
    name: str
    start_time: datetime
    duration_seconds: int

    @field_serializer("start_time")
    def serialize_time(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%dT%H:%M:%SZ")

    @field_serializer("duration_seconds")
    def human_duration(self, value: int) -> str:
        hours, remainder = divmod(value, 3600)
        minutes = remainder // 60
        return f"{hours}h {minutes}m"

Common misconception

Many developers reuse the same model for input validation and output serialization. This is dangerous because input models accept data (they should be strict) while output models expose data (they should be curated). A single model that does both either leaks internal fields or accepts insufficient input.

Always separate your request models from your response models, even if they look similar. The maintenance cost is minimal and the safety benefit is significant.

Envelope patterns

Some APIs wrap responses in a standard envelope:

class PaginatedResponse(BaseModel):
    data: list
    total: int
    page: int
    page_size: int
    has_next: bool

Others use a simpler wrapper with metadata:

class APIResponse(BaseModel):
    success: bool = True
    data: dict | list
    meta: dict | None = None

Pick one pattern and use it consistently across all endpoints. Clients should never guess which format a particular endpoint uses.

The one thing to remember: Define explicit response models that expose only what clients need, separate them from input models, and serialize through those models on every endpoint.

pythonapiserializationjson

See Also