FastAPI JSON Response & Pydantic: Serialization, Validation & OpenAPI

Last updated:

FastAPI automatically serializes Python objects to JSON using Pydantic v2 models — declare a response_model parameter and FastAPI validates, filters, and serializes the response without any manual json.dumps() calls. Pydantic v2 is 5–50× faster than v1 for validation thanks to its Rust core (pydantic-core). FastAPI returns JSONResponse (Content-Type: application/json) by default; use ORJSONResponse from fastapi.responses for a further 3× serialization speedup on large payloads. This guide covers Pydantic BaseModel for request/response typing, response_model and response_model_exclude_unset, custom JSON encoders with model_config, 422 validation error format, streaming JSON responses, and the OpenAPI schema auto-generation.

Pydantic BaseModel: Request Body and Response Model

The foundation of FastAPI's JSON handling is pydantic.BaseModel — a class that declares field names, types, and validation rules as Python type annotations. FastAPI reads the type annotations of route function parameters to decide how to parse incoming JSON: a parameter typed as a BaseModel subclass is parsed from the request body; parameters typed as str, int, or float are parsed from the path or query string. Pydantic v2 validates all input against the declared types and coerces compatible values (e.g., the string "42" into int 42) — invalid input raises a ValidationError that FastAPI converts into a 422 response automatically.

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
import uvicorn

app = FastAPI()

# ── Request body model ────────────────────────────────────────────
class UserCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=100, description="Display name")
    email: EmailStr                         # validated as a real email address
    age: Optional[int] = Field(None, ge=0, le=150)
    roles: list[str] = []                  # default empty list

# ── Response model — controls what JSON FastAPI sends back ────────
class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    roles: list[str]
    # 'password_hash' is NOT here — even if the DB row has it,
    # FastAPI will strip it from the response automatically

# ── Route: POST /users ────────────────────────────────────────────
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
    # FastAPI parsed the JSON body into a UserCreate instance.
    # Pydantic validated: name not empty, email format valid, age >= 0.
    new_user = save_to_db(user)             # returns a dict or ORM model
    return new_user                         # FastAPI serializes via UserResponse

# ── Nested models ─────────────────────────────────────────────────
class Address(BaseModel):
    street: str
    city: str
    country: str = "US"

class CompanyCreate(BaseModel):
    name: str
    address: Address                        # nested model — JSON: {"address": {...}}
    employee_count: int = Field(ge=1)

@app.post("/companies", response_model=CompanyCreate)
async def create_company(company: CompanyCreate):
    # company.address is an Address instance, fully validated
    return company

# ── model_dump() and model_validate() ─────────────────────────────
user_data = {"name": "Alice", "email": "alice@example.com", "roles": ["admin"]}

# Validate a dict into a model
user = UserCreate.model_validate(user_data)

# Serialize a model to a dict
user_dict = user.model_dump()
# {"name": "Alice", "email": "alice@example.com", "age": None, "roles": ["admin"]}

# Serialize to JSON string
user_json = user.model_dump_json()
# '{"name":"Alice","email":"alice@example.com","age":null,"roles":["admin"]}'

Always define separate input models (e.g., UserCreate) and output models (e.g., UserResponse) — they serve different purposes and should not share the same class. Input models declare validation constraints (min_length, ge, EmailStr); output models declare what the client sees. Using the same model for both forces you to either expose internal fields (like password_hash) or add validators that only make sense for input. model_validate() is the Pydantic v2 replacement for parse_obj(); model_dump() replaces dict(); model_dump_json() replaces json().

Path Parameters, Query Parameters, and JSON Validation

FastAPI infers parameter sources from type annotations and default values: path parameters match names in the URL pattern, parameters with Query() defaults come from the query string, and parameters typed as BaseModel subclasses come from the request body. All three sources go through Pydantic validation — a path parameter typed as int that receives a non-numeric string returns 422, not 404. Use Path(), Query(), and Body() from fastapi to add validation constraints, metadata, and examples directly on individual parameters.

from fastapi import FastAPI, Path, Query, Body, HTTPException
from pydantic import BaseModel
from typing import Optional, Annotated

app = FastAPI()

# ── Path parameter with validation ────────────────────────────────
@app.get("/users/{user_id}")
async def get_user(
    user_id: Annotated[int, Path(ge=1, description="Positive integer user ID")]
):
    # FastAPI validates: user_id must be an integer >= 1
    # GET /users/0   → 422 Unprocessable Entity
    # GET /users/abc → 422 Unprocessable Entity
    user = fetch_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

# ── Query parameters with defaults and validation ─────────────────
@app.get("/users")
async def list_users(
    page: Annotated[int, Query(ge=1, description="Page number")] = 1,
    page_size: Annotated[int, Query(ge=1, le=100)] = 20,
    active: Optional[bool] = None,          # ?active=true or ?active=false
    search: Optional[str] = Query(None, min_length=3, max_length=100),
):
    # GET /users?page=2&page_size=50&active=true&search=ali
    return {"page": page, "page_size": page_size, "active": active, "search": search}

# ── Multiple body fields using Body() ─────────────────────────────
@app.put("/users/{user_id}/transfer")
async def transfer_user(
    user_id: int,
    source_org: Annotated[str, Body(description="Source organization ID")],
    target_org: Annotated[str, Body(description="Target organization ID")],
    notify: Annotated[bool, Body()] = True,
):
    # Request body: {"source_org": "org-1", "target_org": "org-2", "notify": false}
    return {"transferred": user_id, "from": source_org, "to": target_org}

# ── Pydantic model as query parameters (Pydantic v2 + FastAPI 0.110+) ──
class UserFilter(BaseModel):
    active: Optional[bool] = None
    role: Optional[str] = None
    min_age: Optional[int] = Field(None, ge=0)

@app.get("/users/filter")
async def filter_users(filters: Annotated[UserFilter, Query()]):
    # GET /users/filter?active=true&role=admin&min_age=18
    # All fields validated by Pydantic
    return {"filters": filters.model_dump(exclude_none=True)}

# ── Enum path parameter ───────────────────────────────────────────
from enum import Enum

class Status(str, Enum):
    active   = "active"
    inactive = "inactive"
    pending  = "pending"

@app.get("/users/status/{status}")
async def users_by_status(status: Status):
    # GET /users/status/active → valid
    # GET /users/status/deleted → 422 (not in enum)
    return {"status": status.value}

Use Annotated[Type, constraint] (Python 3.9+) for parameter annotations — it keeps the type and validation metadata together without modifying function signatures or relying on = Query(...) sentinel defaults. str enum path parameters (extending both str and Enum) validate that the path value matches one of the declared enum members and appear as an OpenAPI enum constraint in the generated schema. For related query parameters that logically belong together, group them into a BaseModel and use Query() as the Annotated annotation — FastAPI flattens the model fields into individual query string parameters automatically.

response_model: Filtering and Transforming JSON Output

The response_model parameter on route decorators is FastAPI's primary tool for controlling JSON output shape. It filters fields not declared in the response model (preventing data leaks), validates the return value against the model schema (catching serialization bugs before they reach clients), and documents the response shape in OpenAPI. Two companion parameters — response_model_exclude_unset and response_model_include / response_model_exclude — provide fine-grained control over which fields appear in the serialized JSON.

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional, Set

app = FastAPI()

# ── Internal model (stored in DB) ─────────────────────────────────
class UserInDB(BaseModel):
    id: int
    name: str
    email: str
    password_hash: str          # never expose this
    internal_notes: str         # never expose this
    is_admin: bool

# ── Public response model ─────────────────────────────────────────
class UserPublic(BaseModel):
    id: int
    name: str
    email: str
    # password_hash and internal_notes are NOT here → stripped from JSON

@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int):
    db_user = fetch_from_db(user_id)        # returns UserInDB
    return db_user                          # FastAPI strips non-public fields

# ── response_model_exclude_unset: omit fields the caller didn't set ──
class UserUpdate(BaseModel):
    name: Optional[str] = None
    email: Optional[str] = None
    age: Optional[int] = None

@app.patch(
    "/users/{user_id}",
    response_model=UserUpdate,
    response_model_exclude_unset=True,      # only return fields that were set
)
async def update_user(user_id: int, update: UserUpdate):
    # If caller sends: {"name": "Alice"}
    # update.model_dump(exclude_unset=True) == {"name": "Alice"}
    # Response JSON: {"name": "Alice"}  (not {"name": "Alice", "email": null, "age": null})
    apply_partial_update(user_id, update.model_dump(exclude_unset=True))
    return update

# ── response_model_include / response_model_exclude ───────────────
@app.get(
    "/users/{user_id}/summary",
    response_model=UserPublic,
    response_model_include={"id", "name"},  # only id and name in JSON
)
async def get_user_summary(user_id: int):
    return fetch_from_db(user_id)

@app.get(
    "/users/{user_id}/public",
    response_model=UserPublic,
    response_model_exclude={"email"},       # everything except email
)
async def get_user_no_email(user_id: int):
    return fetch_from_db(user_id)

# ── Union response models ─────────────────────────────────────────
from typing import Union

class Cat(BaseModel):
    type: str = "cat"
    name: str
    indoor: bool

class Dog(BaseModel):
    type: str = "dog"
    name: str
    breed: str

@app.get("/pets/{pet_id}", response_model=Union[Cat, Dog])
async def get_pet(pet_id: int):
    pet = fetch_pet(pet_id)
    # FastAPI serializes using whichever model validates first
    return pet

# ── response_model=None: skip serialization ───────────────────────
from fastapi.responses import JSONResponse

@app.get("/raw", response_model=None)
async def raw_response():
    # Return a pre-built JSONResponse — bypass response_model entirely
    return JSONResponse(
        content={"status": "ok", "ts": 1716201600},
        status_code=200,
        headers={"Cache-Control": "max-age=60"},
    )

response_model_exclude_unset=True is essential for PATCH endpoints — it lets clients send partial updates without the response echoing null for every unmodified field. Internally, Pydantic tracks which fields were explicitly set during model initialization: model.model_fields_set returns a set of field names. Fields not in that set are omitted when exclude_unset=True. For response_model=None, FastAPI bypasses all serialization and validation — useful when returning a pre-encoded JSONResponse, streaming responses, or binary data. When using Union response models, Pydantic v2 tries each type in order and uses the first that validates successfully — declare more specific types first.

Custom JSON Encoders: datetime, UUID, and Decimal

Pydantic v2 serializes datetime, UUID, and most standard library types automatically when they appear as model field types. For types that Pydantic does not handle natively — Decimal, custom classes, or types requiring non-default formatting — use field_serializer decorators or model_config with json_encoders. When using ORJSONResponse, the orjson library handles datetime, UUID, bytes, and dataclasses natively without any custom configuration.

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel, field_serializer, ConfigDict
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from typing import Any

app = FastAPI()

# ── datetime: Pydantic v2 serializes to ISO 8601 by default ───────
class EventModel(BaseModel):
    id: UUID                            # serialized as "550e8400-e29b-41d4-a716-..."
    name: str
    created_at: datetime                # serialized as "2026-05-20T14:30:00Z"
    updated_at: datetime

@app.get("/events/{event_id}", response_model=EventModel)
async def get_event(event_id: UUID):
    return {
        "id": event_id,
        "name": "Tech Conference",
        "created_at": datetime(2026, 5, 20, 14, 30, tzinfo=timezone.utc),
        "updated_at": datetime(2026, 5, 20, 15, 0, tzinfo=timezone.utc),
    }
    # Response JSON:
    # {"id": "...", "name": "Tech Conference",
    #  "created_at": "2026-05-20T14:30:00Z", "updated_at": "2026-05-20T15:00:00Z"}

# ── Decimal: use field_serializer for precise formatting ──────────
class PriceModel(BaseModel):
    product_id: int
    price: Decimal                      # Decimal is not JSON-serializable natively
    tax_rate: Decimal

    @field_serializer("price", "tax_rate")
    def serialize_decimal(self, value: Decimal) -> float:
        return float(value)             # or: str(value) to preserve precision

@app.get("/products/{product_id}", response_model=PriceModel)
async def get_price(product_id: int):
    return {"product_id": product_id, "price": Decimal("19.99"), "tax_rate": Decimal("0.08")}
    # Response: {"product_id": 1, "price": 19.99, "tax_rate": 0.08}

# ── Custom datetime format via field_serializer ───────────────────
class LogEntry(BaseModel):
    level: str
    message: str
    timestamp: datetime

    @field_serializer("timestamp")
    def serialize_timestamp(self, dt: datetime) -> int:
        return int(dt.timestamp())      # Unix timestamp in seconds instead of ISO string

# ── ORJSONResponse: native datetime/UUID/bytes support ────────────
@app.get("/fast-events", response_model=None)
async def get_fast_events():
    # ORJSONResponse handles datetime and UUID natively — no custom encoder needed
    return ORJSONResponse(content={
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "created_at": datetime.now(timezone.utc),  # serialized as ISO 8601 string
        "data": b"raw bytes here",                  # serialized as base64 string
    })

# ── model_config with arbitrary_types_allowed ─────────────────────
class CustomModel(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)

    name: str
    metadata: dict[str, Any] = {}

    @field_serializer("metadata")
    def serialize_metadata(self, value: dict) -> dict:
        # Custom transformation: stringify all keys, filter None values
        return {str(k): v for k, v in value.items() if v is not None}

# ── Global custom encoder via app.openapi_tags (not recommended) ──
# Prefer field_serializer — it is scoped, testable, and documented.
# Avoid overriding FastAPI's global encoder for Decimal/datetime
# unless you need the same format everywhere AND cannot use ORJSONResponse.

For Decimal fields that must preserve full precision (financial amounts, scientific measurements), serialize as str rather than float — floats lose precision for values like Decimal("0.1") which cannot be represented exactly in IEEE 754. The trade-off is that the JSON value becomes a string ("0.10") rather than a number (0.1), so the client must parse it explicitly. Document this contract in the OpenAPI field description. ORJSONResponse serializes datetime as ISO 8601 and UUID as hyphenated string by default — if you need different formats, wrap the values in a Pydantic model and use response_model instead of returning ORJSONResponse directly.

422 Validation Errors: Format, Handling, and Custom Messages

FastAPI returns 422 Unprocessable Entity whenever Pydantic validation fails on incoming request data — whether the data comes from the path, query string, headers, or request body. The response body is always a structured JSON object with a detail array, where each element describes one validation failure with three fields: loc (field path), msg (human error message), and type (Pydantic error type). Understanding this format is essential for building API clients that display field-level validation feedback to users.

# ── Example 422 response body ─────────────────────────────────────
# POST /users with body: {"name": "", "email": "not-an-email", "age": -5}
#
# HTTP/1.1 422 Unprocessable Entity
# Content-Type: application/json
#
# {
#   "detail": [
#     {
#       "loc": ["body", "name"],
#       "msg": "String should have at least 1 character",
#       "type": "string_too_short",
#       "ctx": {"min_length": 1}
#     },
#     {
#       "loc": ["body", "email"],
#       "msg": "value is not a valid email address",
#       "type": "value_error"
#     },
#     {
#       "loc": ["body", "age"],
#       "msg": "Input should be greater than or equal to 0",
#       "type": "greater_than_equal",
#       "ctx": {"ge": 0}
#     }
#   ]
# }

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, field_validator, model_validator
from typing import Any

app = FastAPI()

# ── Custom 422 error handler ──────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError,
) -> JSONResponse:
    # Reformat the error list to a client-friendly structure
    errors = []
    for error in exc.errors():
        field_path = " -> ".join(str(loc) for loc in error["loc"] if loc != "body")
        errors.append({
            "field": field_path,
            "message": error["msg"],
            "code": error["type"],
        })
    return JSONResponse(
        status_code=422,
        content={"success": False, "errors": errors},
    )

# ── Custom field-level validator ──────────────────────────────────
class SignupRequest(BaseModel):
    username: str
    password: str
    password_confirm: str

    @field_validator("username")
    @classmethod
    def username_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("Username must contain only letters and numbers")
        return v.lower()

    @model_validator(mode="after")
    def passwords_match(self) -> "SignupRequest":
        if self.password != self.password_confirm:
            raise ValueError("Passwords do not match")
        return self

# ── HTTPException for business logic errors (not validation) ──────
from fastapi import HTTPException

@app.post("/signup")
async def signup(data: SignupRequest):
    if username_exists(data.username):
        # 409 Conflict — not a 422, because the data format was valid
        raise HTTPException(
            status_code=409,
            detail={"code": "USERNAME_TAKEN", "message": f"'{data.username}' is already in use"},
        )
    create_user(data)
    return {"created": True}

# ── Catching validation errors programmatically ────────────────────
from pydantic import ValidationError

def parse_webhook_payload(raw: dict[str, Any]) -> SignupRequest | None:
    try:
        return SignupRequest.model_validate(raw)
    except ValidationError as e:
        # e.errors() returns the same list structure as the 422 response
        for err in e.errors():
            print(f"Field: {err['loc']}, Error: {err['msg']}")
        return None

Distinguish between 422 (Pydantic validation failure — the data format is wrong) and 4xx business logic errors raised via HTTPException (the data is valid but violates a business rule, such as a duplicate email or insufficient permissions). Use 422 only for schema/type/constraint violations; use 409 Conflict for duplicate resource errors, 400 Bad Request for malformed but parseable requests, and 403 Forbidden for authorization failures. Custom @field_validator methods that call raise ValueError("message") integrate seamlessly with FastAPI's 422 response — the error message appears in the msg field of the validation error object.

Streaming JSON with StreamingResponse and NDJSON

For large datasets — database exports, real-time event feeds, paginated bulk downloads — buffering the entire response in memory before sending is wasteful and can exhaust server RAM. FastAPI's StreamingResponse accepts an async generator and streams each yielded chunk directly to the client without buffering. NDJSON (Newline-Delimited JSON, also called JSON Lines) is the standard format for streaming: one JSON object per line, each independently parseable as the stream arrives.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import asyncio
import json
import orjson
from typing import AsyncGenerator

app = FastAPI()

# ── NDJSON streaming: one JSON object per line ─────────────────────
async def stream_users_ndjson() -> AsyncGenerator[bytes, None]:
    async for user in fetch_users_from_db_async():
        # Each line is a complete, parseable JSON object
        line = orjson.dumps({"id": user.id, "name": user.name, "email": user.email})
        yield line + b"\n"

@app.get("/users/export")
async def export_users():
    return StreamingResponse(
        stream_users_ndjson(),
        media_type="application/x-ndjson",
        headers={"Content-Disposition": "attachment; filename=users.ndjson"},
    )

# ── Streaming a JSON array (bracket-wrapped) ──────────────────────
async def stream_json_array() -> AsyncGenerator[bytes, None]:
    yield b"["
    first = True
    async for record in fetch_records_async():
        if not first:
            yield b","
        yield orjson.dumps(record)
        first = False
    yield b"]"

@app.get("/records/stream")
async def stream_records():
    return StreamingResponse(
        stream_json_array(),
        media_type="application/json",
    )

# ── Server-Sent Events (SSE) for real-time JSON updates ───────────
async def event_generator() -> AsyncGenerator[str, None]:
    for i in range(10):
        data = json.dumps({"event": "update", "index": i, "value": i * 2})
        yield f"data: {data}\n\n"   # SSE format: "data: <json>\n\n"
        await asyncio.sleep(1)

@app.get("/events/stream")
async def stream_events():
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",   # disable Nginx proxy buffering
        },
    )

# ── Streaming with database cursor (SQLAlchemy async) ─────────────
from sqlalchemy.ext.asyncio import AsyncSession

async def stream_db_rows(session: AsyncSession) -> AsyncGenerator[bytes, None]:
    result = await session.stream(select(User))
    async for row in result:
        user = row[0]
        yield orjson.dumps({
            "id": user.id,
            "name": user.name,
            "created_at": user.created_at.isoformat(),
        }) + b"\n"

# ── Chunked JSON streaming with progress metadata ─────────────────
async def stream_with_progress(total: int) -> AsyncGenerator[bytes, None]:
    processed = 0
    async for batch in fetch_in_batches(batch_size=100):
        for record in batch:
            yield orjson.dumps(record) + b"\n"
            processed += 1
        # Yield a progress event every 100 records
        progress = {"__progress__": processed, "__total__": total}
        yield orjson.dumps(progress) + b"\n"

Set X-Accel-Buffering: no in the response headers to disable Nginx proxy buffering when deploying behind a reverse proxy — without this, Nginx accumulates the entire stream before forwarding it to the client, negating the streaming benefit. For async database streaming, prefer SQLAlchemy's session.stream() (async cursor) over session.execute() (loads all rows into memory). The orjson library is the best choice for streaming serialization — it is significantly faster than json.dumps() and handles datetime, UUID, and bytes natively, eliminating the need for custom serializers in most streaming scenarios.

Auto-Generated OpenAPI JSON Schema

FastAPI generates a complete OpenAPI 3.x specification from your route definitions and Pydantic models, accessible at /openapi.json. The spec is built at startup by inspecting every route decorator, function signature, and Pydantic model — no annotations or decorators are needed beyond the ones you already use for typing and validation. The interactive Swagger UI at /docs and Redoc at /redoc are served from this spec. Use Field() metadata, router tags, and route-level descriptions to produce documentation that is accurate enough to serve as the API contract for client code generation.

from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional

# ── App-level metadata appears in OpenAPI info object ─────────────
app = FastAPI(
    title="My API",
    description="A FastAPI service with full OpenAPI documentation.",
    version="1.0.0",
    contact={"name": "API Support", "email": "api@example.com"},
    license_info={"name": "MIT"},
    # Disable docs in production:
    # docs_url=None, redoc_url=None, openapi_url=None
)

# ── Pydantic Field() adds schema metadata ─────────────────────────
class ProductCreate(BaseModel):
    name: str = Field(
        ...,
        min_length=1,
        max_length=200,
        description="Product display name",
        examples=["Wireless Keyboard"],
    )
    price: float = Field(..., gt=0, description="Price in USD", examples=[49.99])
    sku: Optional[str] = Field(
        None,
        pattern=r"^[A-Z0-9]{6,12}$",
        description="Alphanumeric stock keeping unit",
        examples=["KB001XL"],
    )
    tags: list[str] = Field(default=[], description="Searchable product tags")

# ── Route-level OpenAPI metadata ──────────────────────────────────
@app.post(
    "/products",
    response_model=ProductCreate,
    status_code=201,
    summary="Create a new product",
    description="Creates a product and returns the created resource. "
                "SKU must be 6–12 uppercase alphanumeric characters.",
    response_description="The newly created product",
    tags=["products"],
    operation_id="createProduct",
)
async def create_product(product: ProductCreate):
    return product

# ── Custom OpenAPI schema: add security schemes ───────────────────
from fastapi.openapi.utils import get_openapi

def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    schema = get_openapi(
        title=app.title,
        version=app.version,
        description=app.description,
        routes=app.routes,
    )
    # Add Bearer token security scheme
    schema["components"]["securitySchemes"] = {
        "BearerAuth": {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "JWT",
        }
    }
    schema["security"] = [{"BearerAuth": []}]
    app.openapi_schema = schema
    return schema

app.openapi = custom_openapi

# ── Access schema programmatically ───────────────────────────────
# GET /openapi.json returns the full OpenAPI 3.x specification
# The schema for ProductCreate appears under components/schemas:
# {
#   "ProductCreate": {
#     "type": "object",
#     "properties": {
#       "name":  {"type": "string", "minLength": 1, "maxLength": 200, ...},
#       "price": {"type": "number", "exclusiveMinimum": 0, ...},
#       "sku":   {"type": "string", "pattern": "^[A-Z0-9]{6,12}$", ...},
#       "tags":  {"type": "array", "items": {"type": "string"}, ...}
#     },
#     "required": ["name", "price"]
#   }
# }

# ── Exclude a route from OpenAPI ──────────────────────────────────
@app.get("/internal/health", include_in_schema=False)
async def health():
    return {"status": "ok"}             # not visible in /docs or /openapi.json

FastAPI generates JSON Schema definitions for every Pydantic model used in any route — as request body, query parameter group, or response_model. These definitions are deduplicated and placed under components/schemas in the OpenAPI spec, with $ref pointers used wherever the model appears. Use operation_id on routes when generating client SDKs — tools like openapi-generator use the operation ID as the method name. Cache the custom schema via app.openapi_schema to avoid regenerating it on every /openapi.json request. For further reading on OpenAPI schema patterns, see the guide on OpenAPI JSON schema.

Key Terms

BaseModel
The base class for all Pydantic data models, imported from pydantic. Subclasses declare fields as class-level type annotations — Pydantic reads them at class creation time and builds a validation schema. Instances are created with MyModel(**data) or MyModel.model_validate(dict_or_object); Pydantic validates every field against its declared type and any Field() constraints. Key methods: model_dump() (serialize to dict), model_dump_json() (serialize to JSON string), model_validate() (parse from dict/object), model_json_schema() (generate JSON Schema). In FastAPI, BaseModel subclasses used as function parameter types are automatically parsed from the request body; those used in response_model control JSON output serialization and filtering.
response_model
A parameter on FastAPI route decorators (@app.get, @app.post, etc.) that specifies the Pydantic model used to serialize and filter the route's return value. FastAPI coerces the returned object (dict, ORM instance, or model) into the declared response_model, strips any fields not declared in that model, and serializes the result to JSON. Key companion parameters: response_model_exclude_unset=True (omit fields not explicitly set — useful for PATCH), response_model_include={'field1', 'field2'} (whitelist), and response_model_exclude={'field1'} (blacklist). Setting response_model=None bypasses serialization entirely, allowing JSONResponse or ORJSONResponse to be returned directly. Also drives the OpenAPI response schema for the route.
JSONResponse
FastAPI's default HTTP response class for JSON data, from fastapi.responses (re-exported from Starlette). Takes a content argument (any JSON-serializable Python value), status_code (default 200), and optional headers. Internally uses Python's standard json module with Pydantic's encoder for non-native types. The Content-Type header is set to application/json automatically. Most FastAPI routes do not need to instantiate JSONResponse directly — returning a dict or Pydantic model lets FastAPI create one automatically. Use it explicitly when you need custom status codes, headers, or to bypass response_model serialization. For better performance on large payloads, prefer ORJSONResponse.
ORJSONResponse
A high-performance JSON response class from fastapi.responses that uses the orjson library (Rust-based) instead of Python's json module. Requires pip install orjson. Approximately 3× faster than JSONResponse for typical API payloads and 10× faster for large payloads with many datetime or UUID values. Natively serializes: datetime (as ISO 8601), UUID (hyphenated string), bytes (base64), dataclasses, numpy arrays, and pandas DataFrames. Set as the default response class for all routes via FastAPI(default_response_class=ORJSONResponse). The content argument must be orjson-serializable — types like Decimal still require a custom serializer or pre-conversion to float.
pydantic-core
The Rust extension module that powers Pydantic v2's validation and serialization engine. It replaces the pure-Python validation logic of Pydantic v1 with compiled Rust code, achieving 5–50× faster validation and 2–20× faster serialization depending on model complexity. Installed automatically as a dependency of pydantic>=2.0 — no separate installation required. Exposes a SchemaValidator class that Pydantic uses internally to validate data against compiled schema representations. The performance improvement is most significant for models with many fields, deeply nested structures, or high-throughput APIs processing thousands of requests per second. FastAPI 0.100+ requires Pydantic v2, which means pydantic-core is always present in FastAPI installations.
422 Unprocessable Entity
The HTTP status code FastAPI returns when Pydantic validation fails on any part of the incoming request — path parameters, query parameters, request headers, cookies, or request body. The response body is a JSON object with a detail array; each element has loc (array path to the invalid field, e.g. ["body", "email"]), msg (human-readable error, e.g. "value is not a valid email address"), and type (Pydantic error type identifier, e.g. "value_error"). Defined in RFC 9110 (HTTP Semantics) as the status for requests that are syntactically valid but semantically invalid. Distinct from 400 Bad Request (malformed syntax, e.g. invalid JSON) and 409 Conflict (business rule violation on valid data). Override the default 422 handler with @app.exception_handler(RequestValidationError) to customize the error format.

FAQ

How does FastAPI serialize Python objects to JSON?

FastAPI uses Pydantic v2 as its serialization engine. When a route returns a value, FastAPI calls jsonable_encoder() (which uses Pydantic's model_dump() internally) to convert the value to a JSON-serializable dict, then wraps it in a JSONResponse. If a response_model is declared, FastAPI first validates the return value against that model — stripping undeclared fields — then serializes the filtered result. Pydantic v2's serialization is implemented in Rust (pydantic-core), making it 5–50× faster than Pydantic v1. You never call json.dumps() manually — the entire pipeline from Python object to HTTP response body is handled by FastAPI and Pydantic.

What is response_model in FastAPI?

response_model is a route decorator parameter that tells FastAPI which Pydantic model to use for serializing the route's return value. It serves three purposes: (1) filtering — fields not declared in the response_model are stripped from the response, preventing accidental exposure of sensitive data like password_hash; (2) validation — FastAPI validates the return value against the model schema and raises a 500 if it does not match; (3) documentation — the response schema appears in /openapi.json and the Swagger UI. Use response_model_exclude_unset=True on PATCH endpoints to omit fields not explicitly set in the returned model, reducing payload size for partial update responses.

How do I return a custom JSON response in FastAPI?

Instantiate and return a JSONResponse or ORJSONResponse directly from the route function. For example: return JSONResponse(content={"status": "created"}, status_code=201, headers={"X-Resource-Id": "123"}). When returning a response class directly, FastAPI bypasses response_model serialization — you control the content entirely. For maximum performance, use ORJSONResponse from fastapi.responses (requires pip install orjson), which is ~3× faster and handles datetime and UUID natively. Set ORJSONResponse as the app-wide default with FastAPI(default_response_class=ORJSONResponse) to avoid specifying it on every route.

How does FastAPI handle JSON validation errors?

When Pydantic validation fails on any request input, FastAPI automatically returns a 422 Unprocessable Entity response. The body is a JSON object with a detail array containing one entry per validation failure. Each entry has loc (field path, e.g. ["body", "email"]), msg (human-readable message), and type (Pydantic error type). To customize this behavior, register a handler: @app.exception_handler(RequestValidationError) and return a JSONResponse in your preferred error format. Custom validators added with @field_validator integrate automatically — any ValueError raised inside the validator becomes a standard 422 error entry.

How do I serialize datetime to JSON in FastAPI?

Pydantic v2 serializes datetime fields to ISO 8601 strings automatically — no configuration needed. A datetime(2026, 5, 20, 14, 30, tzinfo=timezone.utc) value becomes "2026-05-20T14:30:00Z" in the JSON output. For custom formats (Unix timestamp, custom string pattern), use a @field_serializer decorator: define a method that takes the datetime value and returns your preferred format (e.g., int(dt.timestamp()) for a Unix timestamp). Always use timezone-aware datetimes to avoid ambiguous UTC/local time bugs — naive datetimes (without tzinfo) are serialized without a timezone suffix. ORJSONResponse also serializes datetime as ISO 8601 natively without any Pydantic model required.

What is the difference between JSONResponse and ORJSONResponse?

JSONResponse uses Python's built-in json module and is available with no extra dependencies — it is FastAPI's default. ORJSONResponse uses the orjson Rust library (pip install orjson) and is approximately 3× faster for serialization. The key practical differences: ORJSONResponse handles datetime, UUID, bytes, dataclasses, and numpy arrays natively — JSONResponse requires manual encoding for these types. For a high-throughput API or any endpoint returning datetime/UUID fields, prefer ORJSONResponse. For simple APIs or cases where you need precise control over every serialization detail, JSONResponse is sufficient. Set the app-wide default with FastAPI(default_response_class=ORJSONResponse).

How do I stream JSON responses in FastAPI?

Use StreamingResponse from fastapi.responses with an async generator. For NDJSON (one JSON object per line), define an async def generator() that yields orjson.dumps(record) + b"\n" for each record, then: return StreamingResponse(generator(), media_type="application/x-ndjson"). For server-sent events, yield f"data: {json.dumps(event)}\n\n" strings with media_type="text/event-stream". Add headers={"X-Accel-Buffering": "no"} to disable Nginx proxy buffering. StreamingResponse writes each chunk to the client immediately without accumulating the entire response in memory — essential for large exports or real-time feeds. Use SQLAlchemy's session.stream() for memory-efficient async database row iteration.

How does FastAPI generate OpenAPI documentation?

FastAPI generates an OpenAPI 3.x specification automatically at startup by inspecting all route decorators and their type annotations. For each route, it reads: the path and HTTP method from the decorator, request parameter types (path, query, body) from the function signature, the response_model for the response schema, and tags, summary, and description for documentation metadata. Pydantic models contribute their field definitions — including Field() descriptions, constraints, and examples — as JSON Schema definitions under components/schemas. The spec is served at /openapi.json; Swagger UI at /docs; Redoc at /redoc. Customize the spec by overriding app.openapi() to add security schemes, servers, or additional metadata. Disable docs in production with FastAPI(docs_url=None, redoc_url=None).

Further reading and primary sources

Related guides: