Response Model and Status Codes
The response_model parameter tells FastAPI exactly what shape your API response will have. It filters sensitive fields, generates OpenAPI schemas, and validates your handler's return value — preventing accidental data leaks.
By the end of this lesson you can: declare response_model on route decorators, filter fields with response_model_exclude, set correct HTTP status codes, and understand how FastAPI validates response data before sending.
Declaring response_model
from fastapi import APIRouter
from pydantic import BaseModel
class UserCreate(BaseModel):
username: str
email: str
password: str # Sensitive — should never be returned
class UserResponse(BaseModel):
id: int
username: str
email: str
# Note: no password field
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate) -> dict:
# Even if this dict has "password", response_model will strip it
return {
"id": 1,
"username": user.username,
"email": user.email,
"password": user.password, # This will be filtered out
}
The response sent to the client will never contain password, regardless of what your handler returns.
Returning the Model Directly
For clarity and type safety, return the Pydantic model instance directly:
from fastapi import APIRouter
from pydantic import BaseModel
class ItemResponse(BaseModel):
id: int
name: str
price: float
@router.get("/{item_id}", response_model=ItemResponse)
async def get_item(item_id: int) -> ItemResponse:
return ItemResponse(id=item_id, name="Widget", price=9.99)
With from_attributes=True in the model config, you can also return SQLAlchemy ORM objects directly.
HTTP Status Codes
| Operation | Status | Use |
|---|---|---|
| Successful GET | 200 | Default |
| Successful POST / creation | 201 | status_code=201 |
| Accepted but not yet done | 202 | Long-running async tasks |
| Deleted with no body | 204 | status_code=204, return None |
| Redirect | 301/302 | RedirectResponse |
| Client error | 4xx | Raise HTTPException |
| Server error | 5xx | Raise or let unhandled exceptions bubble |
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
@router.delete("/{item_id}", status_code=204)
async def delete_item(item_id: int) -> None:
if item_id not in db:
raise HTTPException(404, detail="Item not found")
del db[item_id]
# Return None — FastAPI sends empty 204 body
response_model_exclude and response_model_include
Filter fields at the decorator level without defining a new model:
@router.get("/", response_model=list[UserResponse], response_model_exclude={"email"})
async def list_users() -> list[dict]:
return [{"id": 1, "username": "alice", "email": "alice@example.com"}]
# Email will not appear in the response
Prefer separate models (UserPublic, UserAdmin) over response_model_exclude for complex APIs. Explicit models are easier to test, document, and maintain.
Multiple Response Codes in OpenAPI
Document multiple possible responses:
from fastapi import APIRouter
from pydantic import BaseModel
class ErrorDetail(BaseModel):
detail: str
@router.get(
"/{item_id}",
response_model=ItemResponse,
responses={
404: {"model": ErrorDetail, "description": "Item not found"},
422: {"description": "Validation error"},
},
)
async def get_item(item_id: int) -> ItemResponse:
...
response_model_exclude_unset
When returning partial updates, exclude fields that were not explicitly set:
class ItemUpdate(BaseModel):
name: str | None = None
price: float | None = None
@router.patch("/{item_id}", response_model=ItemUpdate, response_model_exclude_unset=True)
async def patch_item(item_id: int, item: ItemUpdate) -> ItemUpdate:
# Only fields the client sent will appear in the response
return item
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| Sensitive field leaking in response | No response_model or wrong model used | Always set response_model for endpoints that return user data |
| 204 response with body | Returning a value for status_code=204 | Return None (or Response(status_code=204)) for 204 |
| OpenAPI showing wrong schema | Handler return type hint differs from response_model | Match the response_model to what the handler actually returns |
| Validation error on response | Handler returns data that fails response_model validation | Ensure handler output matches the model — check computed fields |
response_model_exclude not working on nested | Excluding a nested field path | Use a separate output model instead |
Hands-On Practice
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class AccountCreate(BaseModel):
username: str = Field(..., min_length=3)
email: EmailStr
password: str = Field(..., min_length=8)
bio: str | None = None
class AccountPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
bio: str | None
class AccountAdmin(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
bio: str | None
router = APIRouter(prefix="/accounts", tags=["accounts"])
_store: dict[int, dict] = {}
_counter = 0
@router.post("/", response_model=AccountPublic, status_code=201)
async def create_account(data: AccountCreate) -> dict:
global _counter
_counter += 1
record = {"id": _counter, "username": data.username, "email": data.email,
"bio": data.bio, "password_hash": "hashed:" + data.password}
_store[_counter] = record
return record # password_hash and email not in AccountPublic — filtered out
@router.get("/{account_id}", response_model=AccountPublic)
async def get_account_public(account_id: int) -> dict:
if account_id not in _store:
raise HTTPException(404, "Account not found")
return _store[account_id]
uvicorn app.routers.accounts:router --reload --port 8006
curl -s -X POST http://localhost:8006/accounts/ \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "alice@example.com", "password": "S3cretPass"}'
# → {"id": 1, "username": "alice", "bio": null} — no email, no password
curl http://localhost:8006/accounts/1