Python Tutorial
Response Models, Status Codes and Error Handling
A good API is predictable about what it returns. FastAPI lets you declare the response model of each endpoint so that output is validated, documented and — importantly — filtered, which stops internal fields such as password hashes from leaking. You also choose the right status code for each outcome and turn failures into clean JSON errors with HTTPException and custom exception handlers.
Response Models
Declare the return type (-> UserOut) or pass response_model=UserOut. FastAPI converts whatever you return into that model, drops fields that are not in it, validates the result and documents it in /docs. A common pattern is three models per resource: UserCreate (input with password), UserInDB (stored, with the hash) and UserOut (public). Use list[UserOut] for collections and response_model_exclude_none=True to omit empty fields.
Status Codes
200 OK— successful read or update (the default).201 Created— a new resource was created:@app.post(..., status_code=201).204 No Content— success with no body, typical for DELETE.400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,409 Conflict— client errors you raise yourself.422 Unprocessable Content— automatic validation errors.500— unhandled server errors.- Use the named constants in
fastapi.status, e.g.status.HTTP_404_NOT_FOUND, for readability.
Raising and Handling Errors
raise HTTPException(status_code=404, detail="Book not found") stops the request and returns {"detail": "Book not found"}; headers= can add headers. For domain errors, define your own exception classes and register a handler with @app.exception_handler(MyError) that returns a JSONResponse — your business code stays free of HTTP details. You can also customise the format of validation errors by handling RequestValidationError.
Examples
Filtering sensitive fields with response models
import hashlib
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
users = []
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserOut(BaseModel):
id: int
username: str
email: str
@app.post("/users", status_code=status.HTTP_201_CREATED)
def register(user: UserCreate) -> UserOut:
stored = {
"id": len(users) + 1,
"username": user.username,
"email": user.email,
"password_hash": hashlib.sha256(user.password.encode()).hexdigest(), # demo only
"is_admin": False,
}
users.append(stored)
return stored # extra keys are filtered out by UserOut
@app.get("/users", response_model=list[UserOut])
def list_users():
return users
client = TestClient(app)
r = client.post("/users", json={"username": "asha", "email": "asha@example.com", "password": "s3cret!"})
print(r.status_code, r.json())
print(client.get("/users").json())
201 {'id': 1, 'username': 'asha', 'email': 'asha@example.com'}
[{'id': 1, 'username': 'asha', 'email': 'asha@example.com'}]
HTTPException, 204 responses and custom headers
from fastapi import FastAPI, HTTPException, Response, status
from fastapi.testclient import TestClient
app = FastAPI()
books = {1: "Python Basics", 2: "Deep Python"}
@app.get("/books/{book_id}")
def get_book(book_id: int):
if book_id not in books:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Book {book_id} not found")
return {"id": book_id, "title": books[book_id]}
@app.post("/books", status_code=status.HTTP_201_CREATED)
def add_book(title: str, response: Response):
if title in books.values():
raise HTTPException(status.HTTP_409_CONFLICT, detail="A book with this title already exists")
book_id = max(books) + 1
books[book_id] = title
response.headers["Location"] = f"/books/{book_id}"
return {"id": book_id, "title": title}
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int):
if books.pop(book_id, None) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Book not found")
client = TestClient(app)
print(client.get("/books/1").json())
r = client.get("/books/9")
print(r.status_code, r.json())
r = client.post("/books?title=SQL Basics")
print(r.status_code, r.json(), r.headers["location"])
print(client.post("/books?title=Deep Python").json())
r = client.delete("/books/2")
print(r.status_code, repr(r.text))
print(client.delete("/books/2").status_code)
{'id': 1, 'title': 'Python Basics'}
404 {'detail': 'Book 9 not found'}
201 {'id': 3, 'title': 'SQL Basics'} /books/3
{'detail': 'A book with this title already exists'}
204 ''
404
Custom exceptions with exception handlers
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
class InsufficientStock(Exception):
def __init__(self, product, available):
self.product = product
self.available = available
app = FastAPI()
STOCK = {"pen": 5, "lamp": 0}
@app.exception_handler(InsufficientStock)
async def stock_handler(request: Request, exc: InsufficientStock):
return JSONResponse(status_code=409, content={
"error": "insufficient_stock", "product": exc.product, "available": exc.available,
})
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
fields = [".".join(str(p) for p in e["loc"][1:]) for e in exc.errors()]
return JSONResponse(status_code=422, content={"error": "invalid_input", "fields": fields})
def reserve(product, quantity): # business logic: no HTTP details here
if STOCK.get(product, 0) < quantity:
raise InsufficientStock(product, STOCK.get(product, 0))
STOCK[product] -= quantity
return STOCK[product]
@app.post("/reserve/{product}")
def reserve_endpoint(product: str, quantity: int):
return {"product": product, "left": reserve(product, quantity)}
client = TestClient(app)
print(client.post("/reserve/pen?quantity=2").json())
r = client.post("/reserve/lamp?quantity=1")
print(r.status_code, r.json())
r = client.post("/reserve/pen?quantity=lots")
print(r.status_code, r.json())
{'product': 'pen', 'left': 3}
409 {'error': 'insufficient_stock', 'product': 'lamp', 'available': 0}
422 {'error': 'invalid_input', 'fields': ['quantity']}
Common Mistakes
- Returning database rows directly without a response model and leaking password hashes or internal flags.
- Returning 200 for everything, or 500 for client mistakes.
- Returning an error dict with status 200 instead of raising HTTPException.
- Sending a body with 204 No Content responses.
- Scattering HTTP status logic through business code instead of using custom exceptions and handlers.
Key Points to Remember
- Response models validate, document and filter output — separate input and output models.
- Choose status codes deliberately: 201 for create, 204 for delete, 404/409 for client errors.
- HTTPException returns {"detail": ...} with the status you choose.
- Custom exceptions plus @app.exception_handler keep business logic independent of HTTP.
- RequestValidationError handlers let you customise 422 responses.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local project environment for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.