Response Models & Status Codes¶
By default FastAPI returns HTTP 200 OK and serializes whatever your function returns as JSON. You can — and often should — control both.
Setting the status code¶
from fastapi import FastAPI, status
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
name: str
age: int
@app.post("/users", status_code=201) # numeric
def create_user(user: User):
return {"created": user}
@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int):
# ... delete from DB
return None
Use status constants instead of magic numbers — status.HTTP_201_CREATED is clearer than 201.
HTTP status codes — quick reference¶
| Range | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK · 201 Created · 204 No Content |
| 3xx | Redirect | 301 Moved · 304 Not Modified |
| 4xx | Client error | 400 Bad Request · 401 Unauthorized · 403 Forbidden · 404 Not Found · 422 Validation Failed |
| 5xx | Server error | 500 Internal Server · 502 Bad Gateway · 503 Unavailable |
Pick the right code per endpoint:
| Endpoint | Status |
|---|---|
POST /resource (created) |
201 |
GET /resource/{id} (success) |
200 |
PUT /resource/{id} (updated) |
200 (or 204 if no body) |
DELETE /resource/{id} |
204 |
| Missing resource | 404 |
| Invalid input | 422 (auto from Pydantic) |
| Not logged in | 401 |
| Logged in but forbidden | 403 |
Response models — what the client sees¶
You usually want to filter what gets sent back. For example, never send the password hash.
from pydantic import BaseModel
class UserIn(BaseModel):
name: str
email: str
password: str # incoming, must be set
class UserOut(BaseModel):
name: str
email: str # NO password field
@app.post("/users", response_model=UserOut)
def create_user(user: UserIn):
# ... hash password, save to DB ...
return user # password attribute gets filtered out automatically
Even though we return the whole user, the client only sees the fields declared in UserOut.
How it executes¶
Client sends {name, email, password}
↓
FastAPI validates against UserIn — all fields required
↓
Function runs with full user data
↓
Function returns user (still has password)
↓
FastAPI sees response_model=UserOut
↓
Strips down to {name, email} only
↓
Client receives sanitized JSON
response_model parameters¶
@app.get("/users/{id}",
response_model=UserOut,
response_model_exclude_unset=True, # don't send fields that weren't set
response_model_exclude_none=True, # don't send None fields
response_model_exclude={"password"}, # explicit field exclusion
response_model_include={"name", "email"}, # only these fields
)
def get_user(id: int):
...
Returning lists¶
@app.get("/users", response_model=list[UserOut])
def list_users():
return [
{"name": "Alice", "email": "alice@x.com"},
{"name": "Bob", "email": "bob@x.com"},
]
Returning files / streaming¶
from fastapi.responses import FileResponse, StreamingResponse
@app.get("/download/report")
def download_report():
return FileResponse("report.pdf", filename="annual_report.pdf")
@app.get("/stream/log")
def stream_log():
def gen():
for i in range(10):
yield f"line {i}\n"
return StreamingResponse(gen(), media_type="text/plain")
Custom headers¶
from fastapi import FastAPI, Response
app = FastAPI()
@app.get("/users/{id}")
def get_user(id: int, response: Response):
response.headers["X-Cache"] = "MISS"
response.headers["X-RateLimit-Remaining"] = "99"
return {"id": id, "name": "Alice"}
Different response types — Tabular cheatsheet¶
| Return type | Sent as |
|---|---|
| Pydantic model | JSON (validated against response_model) |
dict / list |
JSON |
str |
JSON string "..." |
int / float / bool |
JSON number / boolean |
None |
null |
JSONResponse |
Full control over JSON content |
PlainTextResponse |
text/plain |
HTMLResponse |
text/html |
FileResponse |
File download |
RedirectResponse |
3xx redirect |
StreamingResponse |
Streaming bytes |
from fastapi.responses import JSONResponse, PlainTextResponse, HTMLResponse
@app.get("/raw")
def raw():
return PlainTextResponse("Hello!", status_code=200)
@app.get("/page")
def page():
return HTMLResponse("<h1>Hi</h1>", status_code=200)
@app.get("/custom")
def custom():
return JSONResponse(
content={"x": 1},
status_code=201,
headers={"X-Custom": "yes"},
)
A complete example¶
# main.py
from fastapi import FastAPI, status
from pydantic import BaseModel
app = FastAPI()
# Schemas
class UserIn(BaseModel):
name: str
email: str
password: str
class UserOut(BaseModel):
id: int
name: str
email: str
# Fake DB
DB: dict[int, dict] = {}
NEXT_ID = 1
@app.post("/users",
response_model=UserOut,
status_code=status.HTTP_201_CREATED)
def create_user(user_in: UserIn):
global NEXT_ID
record = {"id": NEXT_ID, **user_in.model_dump()}
DB[NEXT_ID] = record
NEXT_ID += 1
return record # password gets stripped because response_model is UserOut
@app.get("/users", response_model=list[UserOut])
def list_users():
return list(DB.values())
@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int):
return DB.get(user_id, {"id": 0, "name": "?", "email": "?"})
Common pitfalls¶
- ❗ Sending sensitive data back — always use a separate
response_modelfor output. Never return raw user records. - ❗ Wrong status code —
POST /resourcethat succeeds should return 201, not 200. - ❗
response_model=UserInfor output — leaks the password. Use a separate output model. - ❗
return Nonewithstatus_code=204— works, but Swagger may show the example asnull. Most clients accept this fine.
What's next¶
Practice¶
What does this print?
Expected: 201
Use 201 (not 200) when creating a new resource via POST
Expected: True
Quiz — Quick check¶
What you remember
Q1. Which status code should a POST that creates a new resource return?
- 200 OK
- 201 Created
- 204 No Content
- 202 Accepted
Why: 200 OK is generic success. 201 Created specifically signals "I made a new resource"—the response should include a
Locationheader pointing to it. RESTful conventions.
Q2. What does response_model=UserOut do in @app.get("/users/{id}", response_model=UserOut)?
- Filters and serializes the return value to match UserOut — extra fields are dropped, validation runs
- Validates input
- Required for OpenAPI
- Just documentation
Why:
response_modelis the schema for the outgoing payload. Even if your function returns a User object with sensitive fields (passwords, internal IDs), only fields inUserOutare serialized. Critical for security.
Q3. When should you use status_code=204?
- When the operation succeeds but there's no body to return (e.g., DELETE)
- For all successful responses
- For unauthorized requests
- Never
Why: 204 No Content explicitly says "success, no body". Better than 200 with an empty body. Common for DELETE endpoints, or actions that just modify state.
Common doubts¶
Why use separate Pydantic models for input vs output?
Different concerns. UserCreate may have a password field; UserOut shouldn't. UserCreate may not have an id; UserOut does. Separating prevents accidentally returning sensitive data or accepting forbidden fields.
Can I return a dict instead of a Pydantic model?
Yes, but you lose validation. FastAPI converts the dict to JSON. With response_model, even a dict is filtered against the model. Best practice: return Pydantic models for type safety and OpenAPI accuracy.
How do I customize the status code dynamically?
Inject response: Response parameter, then response.status_code = 202. Or raise HTTPException(status_code=...) for errors. The decorator's status_code= sets the default; runtime can override.