FastAPI Tutorial Part 4: Response Models and Status Codes - Control Your API Output
Learn to control FastAPI responses with response models, status codes, and headers. Master data filtering, multiple response types, and proper HTTP semantics.
Moshiour Rahman
Advertisement
Why Response Models Matter
Response models control what data your API returns. They ensure consistent output, filter sensitive data, and generate accurate API documentation.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
email: str
password: str # Sensitive!
class UserOut(BaseModel):
username: str
email: str
# password excluded from response
@app.post("/users", response_model=UserOut)
def create_user(user: UserIn):
# Password is filtered out automatically
return user
Basic Response Models
Defining Response Models
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class ItemBase(BaseModel):
name: str
price: float
description: Optional[str] = None
class ItemCreate(ItemBase):
"""Used for creating items (request)."""
pass
class ItemResponse(ItemBase):
"""Used for returning items (response)."""
id: int
created_at: datetime
is_available: bool = True
@app.post("/items", response_model=ItemResponse)
def create_item(item: ItemCreate):
return {
"id": 1,
"name": item.name,
"price": item.price,
"description": item.description,
"created_at": datetime.now(),
"is_available": True
}
List Responses
@app.get("/items", response_model=List[ItemResponse])
def list_items():
return [
{"id": 1, "name": "Item 1", "price": 10.0, "created_at": datetime.now()},
{"id": 2, "name": "Item 2", "price": 20.0, "created_at": datetime.now()}
]
Optional Response Model
@app.get("/items/{item_id}", response_model=Optional[ItemResponse])
def get_item(item_id: int):
item = get_item_from_db(item_id)
if not item:
return None
return item
Filtering Response Data
Exclude Unset Fields
class ItemUpdate(BaseModel):
name: Optional[str] = None
price: Optional[float] = None
description: Optional[str] = None
@app.patch("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: ItemUpdate):
# Only return fields that were actually set
stored = get_item_from_db(item_id)
update_data = item.model_dump(exclude_unset=True)
updated = stored.copy(update=update_data)
return updated
Response Model Parameters
class User(BaseModel):
id: int
username: str
email: str
hashed_password: str
is_active: bool = True
role: str = "user"
# Exclude specific fields
@app.get("/users/{user_id}", response_model=User, response_model_exclude={"hashed_password"})
def get_user(user_id: int):
return users_db[user_id]
# Include only specific fields
@app.get("/users/{user_id}/public", response_model=User, response_model_include={"id", "username"})
def get_user_public(user_id: int):
return users_db[user_id]
# Exclude defaults (unset optional fields)
@app.get("/users", response_model=List[User], response_model_exclude_unset=True)
def list_users():
return list(users_db.values())
# Exclude None values
@app.get("/items", response_model=List[ItemResponse], response_model_exclude_none=True)
def list_items():
return items
HTTP Status Codes
Common Status Codes
| Code | Name | Use Case |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request data |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | Permission denied |
| 404 | Not Found | Resource doesn’t exist |
| 422 | Unprocessable Entity | Validation error |
| 500 | Internal Server Error | Server error |
Setting Status Codes
from fastapi import FastAPI, status
app = FastAPI()
# Using status_code parameter
@app.post("/items", status_code=201)
def create_item(item: ItemCreate):
return {"id": 1, **item.model_dump()}
# Using status constants (recommended)
@app.post("/users", status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate):
return {"id": 1, **user.model_dump()}
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
del items_db[item_id]
return None # 204 should have no body
Dynamic Status Codes
from fastapi import Response
@app.post("/items")
def create_item(item: ItemCreate, response: Response):
if item_exists(item.name):
response.status_code = status.HTTP_200_OK
return get_existing_item(item.name)
else:
response.status_code = status.HTTP_201_CREATED
return create_new_item(item)
Multiple Response Types
Different Response Models
from fastapi import HTTPException
from typing import Union
class ItemFull(BaseModel):
id: int
name: str
price: float
description: str
internal_code: str
class ItemPublic(BaseModel):
id: int
name: str
price: float
@app.get("/items/{item_id}", response_model=Union[ItemFull, ItemPublic])
def get_item(item_id: int, include_internal: bool = False):
item = items_db.get(item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if include_internal:
return ItemFull(**item)
return ItemPublic(**item)
Documenting Multiple Responses
from fastapi import HTTPException
class Item(BaseModel):
id: int
name: str
class ErrorMessage(BaseModel):
detail: str
@app.get(
"/items/{item_id}",
response_model=Item,
responses={
200: {"description": "Item found", "model": Item},
404: {"description": "Item not found", "model": ErrorMessage},
500: {"description": "Internal server error"}
}
)
def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return items_db[item_id]
Complex Response Documentation
@app.get(
"/items",
responses={
200: {
"description": "List of items",
"content": {
"application/json": {
"example": [
{"id": 1, "name": "Widget", "price": 9.99},
{"id": 2, "name": "Gadget", "price": 19.99}
]
}
}
},
204: {"description": "No items found"}
}
)
def list_items():
return items_db.values()
Response Headers
Setting Custom Headers
from fastapi import Response
@app.get("/items")
def list_items(response: Response):
response.headers["X-Total-Count"] = "100"
response.headers["X-Page"] = "1"
return items_db.values()
@app.get("/download")
def download_file(response: Response):
response.headers["Content-Disposition"] = "attachment; filename=data.csv"
return generate_csv()
Cache Headers
@app.get("/static-data")
def get_static_data(response: Response):
response.headers["Cache-Control"] = "public, max-age=3600"
response.headers["ETag"] = '"abc123"'
return {"data": "static content"}
@app.get("/dynamic-data")
def get_dynamic_data(response: Response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return {"timestamp": datetime.now()}
Custom Response Classes
JSON Response
from fastapi.responses import JSONResponse
@app.get("/custom")
def custom_response():
return JSONResponse(
content={"message": "Custom response"},
status_code=200,
headers={"X-Custom": "Header"}
)
Plain Text Response
from fastapi.responses import PlainTextResponse
@app.get("/text", response_class=PlainTextResponse)
def get_text():
return "Hello, World!"
HTML Response
from fastapi.responses import HTMLResponse
@app.get("/page", response_class=HTMLResponse)
def get_page():
return """
<html>
<head><title>My Page</title></head>
<body><h1>Hello, FastAPI!</h1></body>
</html>
"""
Redirect Response
from fastapi.responses import RedirectResponse
@app.get("/old-path")
def redirect_old():
return RedirectResponse(url="/new-path")
@app.get("/external")
def redirect_external():
return RedirectResponse(
url="https://example.com",
status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
Streaming Response
from fastapi.responses import StreamingResponse
import io
@app.get("/stream")
def stream_data():
def generate():
for i in range(100):
yield f"data: {i}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.get("/csv")
def download_csv():
def generate_csv():
yield "id,name,price\n"
for item in items_db.values():
yield f"{item['id']},{item['name']},{item['price']}\n"
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=items.csv"}
)
File Response
from fastapi.responses import FileResponse
@app.get("/download/{filename}")
def download_file(filename: str):
file_path = f"./files/{filename}"
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream"
)
Response Model Inheritance
Base and Extended Models
class ItemBase(BaseModel):
name: str
price: float
class ItemCreate(ItemBase):
pass
class ItemInDB(ItemBase):
id: int
created_at: datetime
updated_at: datetime
class ItemPublic(ItemBase):
id: int
class ItemAdmin(ItemInDB):
internal_notes: str
profit_margin: float
Using Different Models
@app.post("/items", response_model=ItemPublic, status_code=201)
def create_item(item: ItemCreate):
db_item = save_to_db(item)
return db_item # Extra fields filtered out
@app.get("/items/{item_id}", response_model=ItemPublic)
def get_item(item_id: int):
return items_db[item_id]
@app.get("/admin/items/{item_id}", response_model=ItemAdmin)
def admin_get_item(item_id: int):
return items_db[item_id]
Practical Example: E-commerce API Responses
from fastapi import FastAPI, HTTPException, status, Response
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from enum import Enum
app = FastAPI()
# Enums
class OrderStatus(str, Enum):
pending = "pending"
confirmed = "confirmed"
shipped = "shipped"
delivered = "delivered"
cancelled = "cancelled"
# Base Models
class ProductBase(BaseModel):
name: str
price: float
description: Optional[str] = None
class OrderItemBase(BaseModel):
product_id: int
quantity: int
# Request Models
class ProductCreate(ProductBase):
sku: str
cost_price: float # Internal field
class OrderCreate(BaseModel):
items: List[OrderItemBase]
shipping_address: str
# Response Models
class ProductPublic(ProductBase):
id: int
is_available: bool
class ProductAdmin(ProductPublic):
sku: str
cost_price: float
profit_margin: float
created_at: datetime
class OrderItemResponse(OrderItemBase):
product_name: str
unit_price: float
subtotal: float
class OrderResponse(BaseModel):
id: int
status: OrderStatus
items: List[OrderItemResponse]
total: float
shipping_address: str
created_at: datetime
class OrderListItem(BaseModel):
id: int
status: OrderStatus
total: float
item_count: int
created_at: datetime
class PaginatedOrders(BaseModel):
items: List[OrderListItem]
total: int
page: int
per_page: int
pages: int
# Error Models
class ErrorResponse(BaseModel):
detail: str
code: Optional[str] = None
class ValidationErrorResponse(BaseModel):
detail: List[dict]
# Endpoints
@app.post(
"/products",
response_model=ProductPublic,
status_code=status.HTTP_201_CREATED,
responses={
201: {"description": "Product created successfully"},
400: {"model": ErrorResponse, "description": "Invalid product data"}
}
)
def create_product(product: ProductCreate):
"""Create a new product (public response - no internal data)."""
db_product = {
"id": 1,
**product.model_dump(),
"is_available": True,
"created_at": datetime.now(),
"profit_margin": (product.price - product.cost_price) / product.price
}
return db_product
@app.get(
"/products",
response_model=List[ProductPublic],
response_model_exclude_none=True
)
def list_products(
response: Response,
skip: int = 0,
limit: int = 20
):
"""List products with pagination headers."""
products = list(products_db.values())[skip:skip + limit]
response.headers["X-Total-Count"] = str(len(products_db))
response.headers["X-Page-Size"] = str(limit)
return products
@app.get(
"/admin/products/{product_id}",
response_model=ProductAdmin,
responses={
404: {"model": ErrorResponse}
}
)
def admin_get_product(product_id: int):
"""Get product with internal data (admin only)."""
if product_id not in products_db:
raise HTTPException(
status_code=404,
detail="Product not found"
)
return products_db[product_id]
@app.post(
"/orders",
response_model=OrderResponse,
status_code=status.HTTP_201_CREATED,
responses={
201: {"description": "Order created"},
400: {"model": ErrorResponse, "description": "Invalid order"},
422: {"model": ValidationErrorResponse}
}
)
def create_order(order: OrderCreate):
"""Create a new order."""
order_items = []
total = 0
for item in order.items:
product = products_db.get(item.product_id)
if not product:
raise HTTPException(
status_code=400,
detail=f"Product {item.product_id} not found"
)
subtotal = product["price"] * item.quantity
total += subtotal
order_items.append({
"product_id": item.product_id,
"product_name": product["name"],
"quantity": item.quantity,
"unit_price": product["price"],
"subtotal": subtotal
})
return {
"id": 1,
"status": OrderStatus.pending,
"items": order_items,
"total": total,
"shipping_address": order.shipping_address,
"created_at": datetime.now()
}
@app.get(
"/orders",
response_model=PaginatedOrders
)
def list_orders(
page: int = 1,
per_page: int = 10,
status_filter: Optional[OrderStatus] = None
):
"""List orders with pagination."""
orders = list(orders_db.values())
if status_filter:
orders = [o for o in orders if o["status"] == status_filter]
total = len(orders)
pages = (total + per_page - 1) // per_page
start = (page - 1) * per_page
end = start + per_page
return {
"items": [
{
"id": o["id"],
"status": o["status"],
"total": o["total"],
"item_count": len(o["items"]),
"created_at": o["created_at"]
}
for o in orders[start:end]
],
"total": total,
"page": page,
"per_page": per_page,
"pages": pages
}
@app.delete(
"/orders/{order_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={
204: {"description": "Order cancelled"},
404: {"model": ErrorResponse},
400: {"model": ErrorResponse, "description": "Cannot cancel order"}
}
)
def cancel_order(order_id: int):
"""Cancel an order (no response body)."""
if order_id not in orders_db:
raise HTTPException(status_code=404, detail="Order not found")
order = orders_db[order_id]
if order["status"] not in [OrderStatus.pending, OrderStatus.confirmed]:
raise HTTPException(
status_code=400,
detail="Cannot cancel order that has been shipped"
)
orders_db[order_id]["status"] = OrderStatus.cancelled
return None
# In-memory storage
products_db = {}
orders_db = {}
Summary
| Feature | Usage |
|---|---|
response_model | Define output structure |
status_code | Set HTTP status code |
response_model_exclude | Remove specific fields |
response_model_include | Include only specific fields |
responses | Document multiple response types |
Response object | Set headers dynamically |
| Response Class | Use Case |
|---|---|
JSONResponse | Custom JSON with headers |
HTMLResponse | HTML content |
PlainTextResponse | Plain text |
RedirectResponse | URL redirects |
StreamingResponse | Large files, SSE |
FileResponse | File downloads |
Next Steps
In Part 5, we’ll explore Dependency Injection - FastAPI’s powerful system for sharing logic, database connections, authentication, and more across endpoints.
Series Navigation:
- Part 1: Introduction and Setup
- Part 2: Path and Query Parameters
- Part 3: Request Bodies and Pydantic
- Part 4: Response Models and Status Codes (You are here)
- Part 5: Dependency Injection
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
FastAPI Tutorial Part 1: Introduction and Setup - Build Modern Python APIs
Start your FastAPI journey with this comprehensive guide. Learn installation, create your first API, understand async Python, and explore automatic documentation.
PythonFastAPI Tutorial Part 2: Path and Query Parameters - Complete Guide
Master FastAPI path and query parameters. Learn parameter validation, type conversion, optional parameters, and advanced patterns for building flexible APIs.
PythonFastAPI Tutorial: Build Modern Python APIs
Master FastAPI for building high-performance Python APIs. Learn async endpoints, validation, authentication, database integration, and deployment.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.