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 NoneDistinguish 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.jsonFastAPI 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 withMyModel(**data)orMyModel.model_validate(dict_or_object); Pydantic validates every field against its declared type and anyField()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,BaseModelsubclasses used as function parameter types are automatically parsed from the request body; those used inresponse_modelcontrol 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 declaredresponse_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), andresponse_model_exclude={'field1'}(blacklist). Settingresponse_model=Nonebypasses serialization entirely, allowingJSONResponseorORJSONResponseto 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 acontentargument (any JSON-serializable Python value),status_code(default 200), and optionalheaders. Internally uses Python's standardjsonmodule with Pydantic's encoder for non-native types. TheContent-Typeheader is set toapplication/jsonautomatically. Most FastAPI routes do not need to instantiateJSONResponsedirectly — returning a dict or Pydantic model lets FastAPI create one automatically. Use it explicitly when you need custom status codes, headers, or to bypassresponse_modelserialization. For better performance on large payloads, preferORJSONResponse. - ORJSONResponse
- A high-performance JSON response class from
fastapi.responsesthat uses theorjsonlibrary (Rust-based) instead of Python'sjsonmodule. Requirespip install orjson. Approximately 3× faster thanJSONResponsefor 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,numpyarrays, andpandasDataFrames. Set as the default response class for all routes viaFastAPI(default_response_class=ORJSONResponse). Thecontentargument must be orjson-serializable — types likeDecimalstill require a custom serializer or pre-conversion tofloat. - 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 aSchemaValidatorclass 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 meanspydantic-coreis 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
detailarray; each element hasloc(array path to the invalid field, e.g.["body", "email"]),msg(human-readable error, e.g."value is not a valid email address"), andtype(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
- FastAPI: JSON Compatible Encoder — Official FastAPI guide to jsonable_encoder and response serialization internals
- FastAPI: Response Model — Official FastAPI tutorial on response_model, filtering, and response_model_exclude_unset
- Pydantic v2: Serialization — Pydantic v2 model_dump, model_dump_json, field_serializer, and custom encoders documentation
- FastAPI: Custom Response Classes — ORJSONResponse, StreamingResponse, and custom response class configuration
- FastAPI: Request Validation Errors — Handling 422 errors, custom exception handlers, and HTTPException usage
Related guides:
- Python JSON parsing — the
jsonmodule,json.loads(),json.dumps(), and Python serialization patterns - JSON API design — REST API conventions, versioning, error formats, and pagination
- JSON schema validation — validating JSON documents against JSON Schema, AJV, and Pydantic
- OpenAPI JSON schema — OpenAPI 3.x spec structure, components/schemas, and client generation