FastAPI JSON: Return JSON Responses with Pydantic Models
FastAPI automatically serializes Python objects to JSON responses using Pydantic models — returning a dict, list, or Pydantic BaseModel from a route function produces a 200 OK with Content-Type: application/json and no extra code. Pydantic v2 (default since FastAPI 0.100.0, July 2023) serializes models 10–50× faster than v1 using Rust-based validation. response_model=UserOut filters output fields automatically, preventing accidental exposure of password hashes or internal IDs. This guide covers returning JSON from routes, Pydantic request and response models, JSONResponse for custom status codes, response_model_exclude_none=True, streaming JSON with StreamingResponse, and the validation error response format. Use Jsonic's JSON formatter to validate and inspect JSON payloads during development.
Need to validate or inspect a FastAPI JSON payload? Jsonic's JSON formatter parses and prettifies any JSON instantly.
Open JSON FormatterHow FastAPI automatically returns JSON from route functions
Every FastAPI route function that returns a Python value goes through 2 steps before the response is sent. First, FastAPI calls jsonable_encoder() on the return value, converting it to a JSON-safe structure (dicts, lists, strings, numbers, booleans, None). Second, that structure is passed to JSONResponse, which calls json.dumps() and sets Content-Type: application/json. This means you never need to call json.dumps() yourself or set headers manually.
from fastapi import FastAPI
app = FastAPI()
# Returning a dict — simplest form
@app.get("/health")
def health_check():
return {"status": "ok", "version": "1.0.0"}
# HTTP/1.1 200 OK
# Content-Type: application/json
# {"status":"ok","version":"1.0.0"}
# Returning a list of dicts
@app.get("/items")
def list_items():
return [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]jsonable_encoder() handles types that json.dumps() cannot serialize on its own: datetime objects become ISO 8601 strings, UUID becomes a hex string, Decimal becomes a float, Enum becomes its value. Pydantic BaseModel instances are converted via .model_dump() (Pydantic v2) before encoding. If you return a type FastAPI cannot handle — such as a raw SQLAlchemy ORM model without ORM mode enabled — you get a runtime ValueError, not a silent wrong response. Use Python's json module for quick inspection of what a value serializes to outside of FastAPI.
Async and sync route functions both work identically for JSON serialization. FastAPI runs sync functions in a thread pool to avoid blocking the event loop, but the return value handling is the same in both cases.
Pydantic request and response models
Pydantic models serve 2 purposes in FastAPI: parsing and validating incoming JSON request bodies, and filtering and shaping outgoing JSON responses. Using separate input and output models is best practice — it prevents internal fields (password hashes, database IDs, audit timestamps) from leaking into responses, and it allows input validation rules (required fields, value constraints) to differ from output structure.
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr
app = FastAPI()
# Input model — what the client sends
class UserIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
password: str = Field(min_length=8)
# Output model — what the client receives (no password!)
class UserOut(BaseModel):
id: int
name: str
email: str
# response_model= filters the return value through UserOut
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(user: UserIn):
# Simulate DB insert returning a new user with an id
new_user = {"id": 42, "name": user.name, "email": user.email, "password": user.password}
return new_user # password is excluded — not in UserOutThe response_model=UserOut parameter does 3 things: it filters any extra fields from the return value that are not declared in UserOut(preventing accidental leakage), it validates the output matches the declared shape, and it drives the OpenAPI schema documentation. Even if the route function returns a dict with 10 keys, only the 3 keys declared in UserOut appear in the response.
Pydantic v2 validates 10–50× faster than v1. For a high-throughput API handling thousands of requests per second, this difference is measurable. FastAPI 0.100.0+ uses v2 by default. Verify your version with python -c "import pydantic; print(pydantic.__version__)". To validate JSON against a schema in Python, see the dedicated guide which covers both Pydantic and jsonschema approaches.
| Feature | Pydantic v1 | Pydantic v2 |
|---|---|---|
| FastAPI default since | FastAPI < 0.100.0 | FastAPI ≥ 0.100.0 (July 2023) |
| Serialization speed | Baseline | 10–50× faster (Rust core) |
| Model dump method | .dict() | .model_dump() |
| Schema method | .schema() | .model_json_schema() |
| Validation error type strings | value_error.missing | missing, string_type |
| ORM mode config | orm_mode = True | model_config = ConfigDict(from_attributes=True) |
JSONResponse, custom status codes, and ORJSONResponse
The simplest way to return a custom status code is the status_code parameter on the route decorator — FastAPI uses it for every successful response from that route. When the status code must be dynamic (for example, 200 if a resource existed, 201 if it was just created), return a JSONResponse instance directly. There are 3 important response classes to know: JSONResponse, ORJSONResponse, and UJSONResponse.
from fastapi import FastAPI
from fastapi.responses import JSONResponse, ORJSONResponse
app = FastAPI()
# Static status code — simplest approach
@app.post("/items", status_code=201)
def create_item(item: dict):
return item # Always 201
# Dynamic status code with JSONResponse
@app.put("/items/{item_id}")
def upsert_item(item_id: int, item: dict):
existing = db.get(item_id)
if existing:
db.update(item_id, item)
return JSONResponse(content=item, status_code=200)
else:
db.insert(item_id, item)
return JSONResponse(content=item, status_code=201)Important: when you return JSONResponse directly, FastAPI bypasses response_model filtering. The content argument must already be JSON-serializable (plain dicts, lists, strings, numbers, booleans, None). Pass Pydantic models or datetime objects through jsonable_encoder() first:
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
@app.get("/users/{user_id}")
def get_user(user_id: int):
user = UserOut(id=user_id, name="Alice", email="alice@example.com")
# Must encode Pydantic model before passing to JSONResponse
return JSONResponse(content=jsonable_encoder(user), status_code=200)ORJSONResponse uses the orjson library, which is 2–3× faster than the standard library json module for large payloads. Install it with pip install orjson, then use it as a drop-in replacement. For APIs returning large arrays (hundreds of items or nested objects), switching to ORJSONResponse reduces response time measurably. You can set it as the default for your entire app with FastAPI(default_response_class=ORJSONResponse).
Filtering None fields: response_model_exclude_none and exclude_unset
By default, FastAPI includes all fields declared in the response_model, even those with a None value. This can produce verbose responses when your model has many optional fields. 3 route-level options control which fields are included in the output:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class UserProfile(BaseModel):
id: int
name: str
bio: Optional[str] = None
website: Optional[str] = None
twitter: Optional[str] = None
@app.get(
"/users/{user_id}",
response_model=UserProfile,
response_model_exclude_none=True, # omit fields with value None
)
def get_user(user_id: int):
# Only id and name are set — bio, website, twitter are None
return {"id": user_id, "name": "Alice", "bio": None, "website": None}
# Response: {"id": 1, "name": "Alice"} ← None fields omitted# response_model_exclude_unset=True — omit fields never assigned a value
@app.get("/users/{user_id}", response_model=UserProfile, response_model_exclude_unset=True)
def get_user_sparse(user_id: int):
user = UserProfile(id=user_id, name="Alice") # bio/website/twitter never set
return user
# Response: {"id": 1, "name": "Alice"} ← unset fields omitted
# response_model_exclude={"internal_id", "created_at"} — always remove named fields
@app.get("/users/{user_id}", response_model=UserProfile, response_model_exclude={"bio"})
def get_user_no_bio(user_id: int):
return {"id": user_id, "name": "Alice", "bio": "Always removed"}
# Response: {"id": 1, "name": "Alice"} ← bio excluded regardless of valueThe difference between exclude_none and exclude_unset: exclude_none removes any field whose value is None at response time; exclude_unset removes fields that were never assigned a value and kept their model default (which might not be None — it could be 0, an empty string, or a default list). Use exclude_none for sparse optional data. Use exclude_unset for PATCH-style partial update payloads where you only want to include fields the user explicitly provided. These options only apply when response_model is set — they have no effect if you return JSONResponse directly. For inspecting JSON responses during development, use the Jsonic JSON formatter to prettify and validate the output.
Validation error response format (HTTP 422)
When request data fails Pydantic validation, FastAPI returns HTTP 422 Unprocessable Entity with a structured JSON body. The body has a single detail key containing an array of error objects — one per validation failure. Understanding this format is essential for building clients that display useful error messages.
// POST /items with invalid body: {"name": "", "price": -5}
// HTTP 422 Unprocessable Entity
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "name"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": { "min_length": 1 }
},
{
"type": "greater_than",
"loc": ["body", "price"],
"msg": "Input should be greater than 0",
"input": -5,
"ctx": { "gt": 0 }
}
]
}Each error object has 4 fields: type — machine-readable Pydantic v2 error type (differs from v1); loc — location path as an array (e.g., ["body", "address", "zip"] for a nested field, ["query", "limit"] for a query parameter); msg — human-readable message; input — the value that failed validation; and optionally ctx — additional context such as the constraint value. The first element of loc indicates the source: "body", "query", "path", or "header".
# Customize the 422 handler — return a simpler error format
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [
{"field": ".".join(str(l) for l in e["loc"][1:]), "message": e["msg"]}
for e in exc.errors()
]
return JSONResponse(status_code=422, content={"errors": errors})For validating JSON payloads against a schema before they reach FastAPI, see the Python JSON Schema validation guide. To understand what FastAPI's 422 responses look like in practice, paste the response body into the Jsonic formatter to explore the structure interactively.
Streaming JSON with StreamingResponse and NDJSON
For large datasets or real-time feeds, StreamingResponse lets the client start receiving data before the full response is ready. FastAPI streams the response body as the generator yields chunks. The 2 most common patterns are newline-delimited JSON (NDJSON) and server-sent events (SSE). Neither requires any extra dependencies beyond FastAPI itself.
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
# Pattern 1: NDJSON — one JSON object per line
# Each line is a complete, parseable JSON object
async def generate_users():
users = [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
{"id": 3, "name": "Carol"},
]
for user in users:
yield json.dumps(user) + "\n"
@app.get("/users/stream")
def stream_users():
return StreamingResponse(generate_users(), media_type="application/x-ndjson")# Pattern 2: Server-sent events (SSE)
# Client uses EventSource API; each event is "data: {...}\n\n"
import asyncio
async def generate_events():
for i in range(5):
await asyncio.sleep(1) # Simulate real-time data
event_data = json.dumps({"event": "update", "count": i})
yield f"data: {event_data}\n\n"
@app.get("/events")
def stream_events():
return StreamingResponse(generate_events(), media_type="text/event-stream")StreamingResponse bypasses response_model — you are responsible for serializing each chunk. Use json.dumps(item) or, for better performance with large objects, orjson.dumps(item).decode(). Async generators (async def generate() with yield) allow awaiting database queries or HTTP calls inside the generator without blocking the event loop. Sync generators also work but run in a thread pool.
# Streaming a large database result set — async generator with DB query
from sqlalchemy.ext.asyncio import AsyncSession
async def stream_records(db: AsyncSession):
async for row in db.stream(select(Item)):
yield json.dumps({"id": row.id, "name": row.name}) + "\n"
@app.get("/items/export")
async def export_items(db: AsyncSession = Depends(get_db)):
return StreamingResponse(stream_records(db), media_type="application/x-ndjson")For very large payloads that fit in memory, ORJSONResponse is simpler than StreamingResponse: it serializes the entire payload at once usingorjson and is 2–3× faster than the default JSONResponse. Use StreamingResponse only when the dataset is too large to hold in memory or when you need real-time delivery. To understand the JSON formats you are streaming, see the guide on REST API JSON response design and the json.dumps() Python guide for serialization options.
Key terms and definitions
Understanding the 6 core concepts in FastAPI JSON handling prevents the most common mistakes — especially the JSONResponse bypass of response_model and the difference between ORJSONResponse and the default response class.
| Term | Definition | Key behavior |
|---|---|---|
| Pydantic BaseModel | Base class for data models with automatic type validation | v2 (Rust core): 10–50× faster than v1; use .model_dump() not .dict() |
| response_model | Route parameter specifying the output Pydantic model | Filters extra fields, validates output, drives OpenAPI docs; bypassed by JSONResponse |
| JSONResponse | Starlette response class for JSON with custom status/headers | Content must be pre-serializable; bypasses response_model; use jsonable_encoder() first |
| ORJSONResponse | FastAPI response class using orjson library | 2–3× faster than JSONResponse; requires pip install orjson |
| StreamingResponse | Response class that streams body from a generator | Use for large datasets or real-time feeds; bypasses response_model; serialize each chunk manually |
| HTTP 422 | Unprocessable Entity — Pydantic validation failure | Body: {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}; one object per error |
Frequently asked questions
How does FastAPI automatically return JSON from a route function?
FastAPI calls jsonable_encoder() on whatever your route function returns, then passes the result to JSONResponse, which serializes it with Python's json.dumps() and sets Content-Type: application/json. This works automatically for dicts, lists, Pydantic BaseModel instances, dataclasses, and most Python primitives — you do not need to call json.dumps() yourself or set any headers. jsonable_encoder() handles types that json.dumps() cannot serialize on its own: datetime objects become ISO 8601 strings, UUID becomes a hex string, Decimal becomes a float, Enum becomes its value. Pydantic BaseModel instances are converted via .model_dump() (Pydantic v2) before encoding. If you return a type FastAPI cannot handle — such as a raw SQLAlchemy model without ORM mode enabled via model_config = ConfigDict(from_attributes=True) — you get a runtime ValueError, not a silent wrong response. Async and sync route functions both serialize identically; FastAPI runs sync functions in a thread pool to avoid blocking the event loop. Use Python's json module to inspect what a value serializes to outside of FastAPI.
How do I use Pydantic models for JSON request body validation in FastAPI?
Declare a Pydantic BaseModel subclass and use it as a type annotation on the route function parameter. FastAPI reads the request body, parses it as JSON, and validates it against the model — returning HTTP 422 automatically if validation fails. Example: define class Item(BaseModel): name: str; price: float = Field(gt=0), then use @app.post("/items") async def create(item: Item): return item. FastAPI infers from the BaseModel type that the parameter is a request body (not a path or query parameter). Pydantic v2 (default since FastAPI 0.100.0) validates 10–50× faster than v1 using Rust-based validation. You can add constraints with Field(): Field(gt=0) for greater than 0, Field(min_length=1, max_length=100) for string length, Field(pattern=r"^\d5$") for regex. Nested models, Optional fields, and List types are all supported. To validate JSON against a schema in Python outside of FastAPI, see the dedicated guide covering both Pydantic and jsonschema approaches.
How do I return a custom HTTP status code with a JSON response in FastAPI?
There are 2 ways. The simplest is the status_code parameter on the route decorator: @app.post("/items", status_code=201) — FastAPI returns that status code for all successful responses from that route, and it is documented in the OpenAPI schema. The second way is to return a JSONResponse instance directly: return JSONResponse(content=data, status_code=201) — use this when the status code is dynamic (for example, returning 200 for an update or 201 for a create). Important: when you return JSONResponse directly, FastAPI bypasses response_model filtering and serialization — the content argument must already be JSON-serializable. Pydantic models and datetime values are not JSON-serializable by default; call jsonable_encoder(data) on them first. For error responses, use raise HTTPException(status_code=404, detail="Not found") — FastAPI catches this and returns {"detail": "Not found"} with the specified status code. For REST API JSON response design, see the dedicated guide covering status codes and response body conventions.
How do I exclude None fields from a FastAPI JSON response?
Set response_model_exclude_none=True on the route decorator. FastAPI will omit any field whose value is None from the serialized JSON output: @app.get("/users/{id}", response_model=UserOut, response_model_exclude_none=True). This is useful when your model has many Optional fields and you want clean responses that only include populated data. A related option, response_model_exclude_unset=True, omits fields that were never explicitly assigned a value (they kept their model default, which might not be None). The difference: exclude_none checks the value at response time; exclude_unset checks whether the field was ever set during model construction — useful for PATCH-style responses where you only include fields the user explicitly provided. A third option, response_model_exclude=field_name, removes specific named fields regardless of their value. All 3 options only apply when response_model is set — they have no effect if you return JSONResponse directly.
What does a FastAPI validation error JSON response look like?
When request data fails Pydantic validation, FastAPI returns HTTP 422 Unprocessable Entity with a body structured as {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}. The detail array contains 1 object per validation error. Each object has: loc — location path as an array, where the first element is the source ("body", "query", "path", "header") and subsequent elements are the field path; msg — human-readable description; type — machine-readable Pydantic v2 error type (e.g., "missing", "string_too_short", "greater_than"); input — the value that failed; and optionally ctx — constraint context. Pydantic v2 type strings differ from v1 (v1 used "value_error.missing", v2 uses "missing"). You can customize the 422 handler by registering a RequestValidationError exception handler on the FastAPI app. Paste any 422 response into the Jsonic formatter to explore the error structure.
How do I return a streaming JSON response in FastAPI?
Use StreamingResponse with a generator that yields JSON chunks. The most common pattern is newline-delimited JSON (NDJSON), where each line is a complete JSON object: define an async generator that yields json.dumps(item) + "\n" for each item, then return StreamingResponse(generator(), media_type="application/x-ndjson"). This is ideal for large datasets or real-time feeds where you want the client to start receiving data before the full response is ready. For server-sent events (SSE), yield lines in the format data: {...}\n\n with media_type="text/event-stream" — the client uses the browser EventSource API or fetch() with a reader. Important: StreamingResponse bypasses response_model — you must serialize each chunk manually using json.dumps() or, for better performance, orjson.dumps(item).decode(). Async generators allow awaiting database queries inside the generator without blocking the event loop. For very large in-memory payloads, ORJSONResponse is simpler: it serializes everything at once and is 2–3× faster than the default JSONResponse. See the json.dumps() Python guide for serialization options used inside streaming generators.
Ready to work with FastAPI JSON responses?
Use Jsonic's JSON formatter to validate and prettify FastAPI request and response bodies during development. You can also use the JSON validator to catch syntax errors before sending payloads to your API.
Open JSON Formatter