Skip to content

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_model for output. Never return raw user records.
  • Wrong status codePOST /resource that succeeds should return 201, not 200.
  • response_model=UserIn for output — leaks the password. Use a separate output model.
  • return None with status_code=204 — works, but Swagger may show the example as null. Most clients accept this fine.

What's next

Practice

What does this print?

Expected: 201

# HTTP status codes: 2xx = success, 4xx = client error, 5xx = server error
CREATED = 201
print(CREATED)

Use 201 (not 200) when creating a new resource via POST

Expected: True

post_status = 200       # bug: 201 Created is more accurate for resource creation
is_201 = post_status == 201
print(not is_201)

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 Location header 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_model is the schema for the outgoing payload. Even if your function returns a User object with sensitive fields (passwords, internal IDs), only fields in UserOut are 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.

Error Handling with HTTPException