FastAPI Tutorial Series Part 3: Error Handling and Status Codes
Understanding how to handle errors properly is crucial for building robust APIs. In this part, we'll learn how to return appropriate status codes and create meaningful error messages.
HTTP Status Codes Quick Reference
Before we dive in, here are the most common status codes:
- 200 OK: Request succeeded
- 201 Created: Resource created successfully
- 400 Bad Request: Client sent invalid data
- 404 Not Found: Resource doesn't exist
- 500 Internal Server Error: Something went wrong on the server
Default Status Codes in FastAPI
FastAPI automatically returns appropriate status codes:
from fastapi import FastAPI
app = FastAPI()
@app.get("/items") # Returns 200 by default
def get_items():
return {"items": []}
@app.post("/items") # Returns 200 by default
def create_item():
return {"message": "Item created"}
But 200 OK isn't always the best choice for POST requests.
Setting Custom Status Codes
For a POST request that creates a resource, 201 Created is more appropriate:
from fastapi import FastAPI, status
app = FastAPI()
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(name: str, price: float):
return {"name": name, "price": price}
The status module provides readable names for all HTTP status codes.
Raising HTTP Exceptions
When something goes wrong, use HTTPException to return an error:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
items = {
1: {"name": "Laptop", "price": 999.99},
2: {"name": "Mouse", "price": 29.99}
}
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id not in items:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with id {item_id} not found"
)
return items[item_id]
Try accessing:
-
http://127.0.0.1:8000/items/1(exists) -
http://127.0.0.1:8000/items/999(doesn't exist)
Custom Error Messages
You can provide detailed error information:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: int):
if user_id < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User ID must be a positive integer",
headers={"X-Error": "Invalid user ID"}
)
if user_id > 100:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": "User not found",
"user_id": user_id,
"suggestion": "Check if the user ID is correct"
}
)
return {"user_id": user_id, "name": "John Doe"}
Building a Complete TODO API with Error Handling
Let's put everything together with a practical example:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional, List
app = FastAPI()
class Todo(BaseModel):
title: str
description: Optional[str] = None
completed: bool = False
class TodoInDB(Todo):
id: int
# In-memory database
todos_db: List[TodoInDB] = []
next_id = 1
@app.post("/todos", response_model=TodoInDB, status_code=status.HTTP_201_CREATED)
def create_todo(todo: Todo):
global next_id
# Check if todo with same title exists
for existing_todo in todos_db:
if existing_todo.title.lower() == todo.title.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Todo with title '{todo.title}' already exists"
)
todo_in_db = TodoInDB(**todo.dict(), id=next_id)
todos_db.append(todo_in_db)
next_id += 1
return todo_in_db
@app.get("/todos", response_model=List[TodoInDB])
def get_todos(completed: Optional[bool] = None):
if completed is None:
return todos_db
return [todo for todo in todos_db if todo.completed == completed]
@app.get("/todos/{todo_id}", response_model=TodoInDB)
def get_todo(todo_id: int):
for todo in todos_db:
if todo.id == todo_id:
return todo
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
@app.put("/todos/{todo_id}", response_model=TodoInDB)
def update_todo(todo_id: int, todo_update: Todo):
for index, todo in enumerate(todos_db):
if todo.id == todo_id:
updated_todo = TodoInDB(**todo_update.dict(), id=todo_id)
todos_db[index] = updated_todo
return updated_todo
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
for index, todo in enumerate(todos_db):
if todo.id == todo_id:
todos_db.pop(index)
return
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
@app.patch("/todos/{todo_id}/complete", response_model=TodoInDB)
def mark_complete(todo_id: int):
for todo in todos_db:
if todo.id == todo_id:
todo.completed = True
return todo
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
Testing the Error Handling
Using the docs at http://127.0.0.1:8000/docs, try:
- Create a todo (POST /todos):
{
"title": "Learn FastAPI",
"description": "Complete the tutorial series"
}
- Try creating a duplicate (should fail with 400):
{
"title": "Learn FastAPI",
"description": "Different description"
}
-
Get a non-existent todo (should fail with 404):
- GET
/todos/999
- GET
Update and delete todos to see different status codes
Common Status Codes in RESTful APIs
Here's when to use each:
# 200 OK - Successful GET, PUT, PATCH
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"id": item_id}
# 201 Created - Successful POST
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(name: str):
return {"name": name}
# 204 No Content - Successful DELETE (no body returned)
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
pass # Just delete, don't return anything
# 400 Bad Request - Invalid input
if not name:
raise HTTPException(status_code=400, detail="Name is required")
# 404 Not Found - Resource doesn't exist
if item_id not in database:
raise HTTPException(status_code=404, detail="Item not found")
# 409 Conflict - Resource already exists
if name in database:
raise HTTPException(status_code=409, detail="Item already exists")
Validation Errors
FastAPI automatically handles validation errors with 422 Unprocessable Entity:
from pydantic import BaseModel, validator
class Item(BaseModel):
name: str
price: float
@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v
@app.post("/items")
def create_item(item: Item):
return item
Try sending a negative price:
{
"name": "Laptop",
"price": -100
}
FastAPI will return a detailed error about the validation failure.
Creating Custom Exception Handlers
For more control, create custom exception handlers:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class ItemNotFoundError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id
@app.exception_handler(ItemNotFoundError)
def item_not_found_handler(request: Request, exc: ItemNotFoundError):
return JSONResponse(
status_code=404,
content={
"error": "Item not found",
"item_id": exc.item_id,
"path": str(request.url)
}
)
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id > 100:
raise ItemNotFoundError(item_id=item_id)
return {"id": item_id, "name": "Sample Item"}
Best Practices
-
Always use appropriate status codes
- Don't return 200 for errors
- Use 201 for creation
- Use 204 for successful deletion
-
Provide helpful error messages
- Tell users what went wrong
- Suggest how to fix it
- Include relevant IDs or data
-
Be consistent
- Use the same error format across your API
- Document your error responses
-
Don't expose sensitive information
- Don't reveal database structure
- Don't show stack traces in production
What's Next?
In Part 4, we'll cover:
- Advanced Pydantic validation
- Response models and data filtering
- File uploads
- Background tasks
How do you handle errors in your APIs? Share your strategies in the comments!
Top comments (0)