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 Formatter

How 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 UserOut

The 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.

FeaturePydantic v1Pydantic v2
FastAPI default sinceFastAPI < 0.100.0FastAPI ≥ 0.100.0 (July 2023)
Serialization speedBaseline10–50× faster (Rust core)
Model dump method.dict().model_dump()
Schema method.schema().model_json_schema()
Validation error type stringsvalue_error.missingmissing, string_type
ORM mode configorm_mode = Truemodel_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 value

The 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.

TermDefinitionKey behavior
Pydantic BaseModelBase class for data models with automatic type validationv2 (Rust core): 10–50× faster than v1; use .model_dump() not .dict()
response_modelRoute parameter specifying the output Pydantic modelFilters extra fields, validates output, drives OpenAPI docs; bypassed by JSONResponse
JSONResponseStarlette response class for JSON with custom status/headersContent must be pre-serializable; bypasses response_model; use jsonable_encoder() first
ORJSONResponseFastAPI response class using orjson library2–3× faster than JSONResponse; requires pip install orjson
StreamingResponseResponse class that streams body from a generatorUse for large datasets or real-time feeds; bypasses response_model; serialize each chunk manually
HTTP 422Unprocessable Entity — Pydantic validation failureBody: {"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