FastAPI API Design
In this guide, we'll explore best practices for designing clean, efficient, and maintainable APIs using FastAPI. Good API design makes your endpoints intuitive, consistent, and easy to maintain as your project grows.
Introduction
FastAPI is a modern, high-performance web framework for building APIs with Python. While it's easy to get started with FastAPI, designing APIs that are maintainable, scalable, and follow industry best practices requires careful planning and structure.
A well-designed API provides:
- Intuitive endpoints that are easy to understand
- Consistent patterns across your application
- Clear documentation that helps users understand your API
- Proper error handling and status codes
- Optimized performance for your use case
Let's dive into the key principles of FastAPI API design.
Organizing Your API Structure
Using APIRouter for Clear Organization
FastAPI's APIRouter
allows you to organize your endpoints into logical groups:
from fastapi import APIRouter, FastAPI
app = FastAPI()
# Create routers for different resource types
user_router = APIRouter(prefix="/users", tags=["Users"])
product_router = APIRouter(prefix="/products", tags=["Products"])
# Define endpoints on the routers
@user_router.get("/")
async def get_users():
return {"users": ["John", "Jane", "Bob"]}
@product_router.get("/")
async def get_products():
return {"products": ["Laptop", "Phone", "Tablet"]}
# Include the routers in your main app
app.include_router(user_router)
app.include_router(product_router)
This approach offers several benefits:
- Keeps related endpoints together
- Applies common prefixes and tags automatically
- Makes your code more modular and maintainable
- Improves API documentation organization
Project Structure for Larger Applications
For larger applications, consider organizing your files like this:
my_api/
├── main.py # Main FastAPI application
├── dependencies.py # Shared dependencies
├── routers/ # API route modules
│ ├── users.py
│ ├── products.py
│ └── auth.py
├── models/ # Pydantic models for requests/responses
│ ├── user.py
│ └── product.py
└── services/ # Business logic
├── user_service.py
└── product_service.py
Path Operation Decorators and Status Codes
Using the Right HTTP Methods
FastAPI provides decorators for all standard HTTP methods:
@router.get("/items/") # Read operations
@router.post("/items/") # Create operations
@router.put("/items/{id}") # Full update operations
@router.patch("/items/{id}") # Partial update operations
@router.delete("/items/{id}") # Delete operations
Choose the appropriate method based on what your endpoint does:
GET
for retrieving dataPOST
for creating new resourcesPUT
for completely replacing resourcesPATCH
for partially updating resourcesDELETE
for removing resources
Setting Appropriate Status Codes
Always set the right status code for your responses:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.post("/items/", status_code=status.HTTP_201_CREATED)
async def create_item(name: str):
return {"name": name}
@app.get("/items/{item_id}")
async def get_item(item_id: int):
if item_id < 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found"
)
return {"item_id": item_id}
Common status codes to use:
200
OK (default) - Successful GET, PUT, PATCH201
Created - Successful POST that creates a resource204
No Content - Successful operation with no response body (like DELETE)400
Bad Request - Invalid input401
Unauthorized - Authentication required403
Forbidden - Permission denied404
Not Found - Resource doesn't exist422
Unprocessable Entity - Validation error
Request and Response Models
Define Clear Pydantic Models
Use Pydantic models to define your request and response schemas:
from pydantic import BaseModel, Field
from typing import Optional, List
from fastapi import FastAPI
app = FastAPI()
class ItemBase(BaseModel):
name: str = Field(..., example="Smartphone")
description: Optional[str] = Field(None, example="A high-end smartphone")
price: float = Field(..., gt=0, example=499.99)
tax: Optional[float] = Field(None, ge=0, example=49.99)
tags: List[str] = Field(default_factory=list, example=["electronics", "gadgets"])
class ItemCreate(ItemBase):
pass
class ItemResponse(ItemBase):
id: int = Field(..., example=1)
class Config:
orm_mode = True # Allows direct use with ORM models
@app.post("/items/", response_model=ItemResponse)
async def create_item(item: ItemCreate):
# Here you would typically save to a database
# For this example, we'll just create a mock response
return {**item.dict(), "id": 1}
Benefits of using Pydantic models:
- Automatic validation of input data
- Automatic data conversion
- Auto-generated API documentation
- Clear separation between input and output models
Response Model Configuration
You can control what's included in your response with additional options:
@app.get(
"/items/{item_id}",
response_model=ItemResponse,
response_model_exclude_unset=True, # Don't include default values
response_model_exclude={"description"} # Exclude specific fields
)
async def get_item(item_id: int):
return {"id": item_id, "name": "Example", "price": 99.9, "tags": []}
Path Parameters, Query Parameters, and Request Bodies
When to Use Different Parameter Types
FastAPI provides different ways to receive input:
from fastapi import FastAPI, Query, Path, Body
app = FastAPI()
@app.get("/items/{item_id}")
async def get_item(
# Path parameters - for identifying specific resources
item_id: int = Path(..., title="The ID of the item", ge=1),
# Query parameters - for filtering, sorting, pagination
q: str = Query(None, max_length=50, description="Search query"),
skip: int = Query(0, ge=0, description="Number of items to skip"),
limit: int = Query(10, ge=0, le=100, description="Max items to return")
):
return {
"item_id": item_id,
"query": q,
"pagination": {"skip": skip, "limit": limit}
}
class ItemUpdate(BaseModel):
name: Optional[str] = None
price: Optional[float] = None
@app.patch("/items/{item_id}")
async def update_item(
# Path parameter
item_id: int = Path(..., title="The ID of the item to update", ge=1),
# Request body
item: ItemUpdate = Body(..., title="Item update data")
):
return {"item_id": item_id, "updated": item}
Guidelines for parameter usage:
- Path parameters: For identifying specific resources (e.g.,
/users/{user_id}
) - Query parameters: For optional parameters, filtering, sorting, pagination
- Request body: For complex data structures, especially in POST, PUT, PATCH operations
Dependency Injection
FastAPI's dependency injection system helps you manage code reuse, security, validation, and more:
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
app = FastAPI()
# Simple dependency example
async def common_parameters(q: str = None, skip: int = 0, limit: int = 100):
return {"q": q, "skip": skip, "limit": limit}
@app.get("/items/")
async def read_items(commons: dict = Depends(common_parameters)):
return {"commons": commons}
# Authentication dependency
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
# In a real app, you would verify the token and get the user
if token != "valid_token":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return {"username": "testuser"}
@app.get("/users/me")
async def read_users_me(current_user: dict = Depends(get_current_user)):
return current_user
Benefits of dependency injection:
- Reduces code duplication
- Centralizes common functionality
- Makes testing easier through mocking
- Creates reusable components
Error Handling
Proper error handling makes your API more user-friendly:
from fastapi import FastAPI, HTTPException, status, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
app = FastAPI()
# Custom exception class
class ItemNotFoundError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id
self.message = f"Item with ID {item_id} not found"
# Exception handler
@app.exception_handler(ItemNotFoundError)
async def item_not_found_handler(request: Request, exc: ItemNotFoundError):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": exc.message}
)
@app.get("/items/{item_id}")
async def get_item(item_id: int):
if item_id == 999: # Simulate item not found
raise ItemNotFoundError(item_id)
return {"item_id": item_id}
# Using standard HTTPException
@app.get("/products/{product_id}")
async def get_product(product_id: int):
if product_id == 999: # Simulate product not found
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found",
headers={"X-Error-Code": "PRODUCT_NOT_FOUND"}, # Optional custom headers
)
return {"product_id": product_id}
Best practices for error handling:
- Use appropriate HTTP status codes
- Provide clear error messages
- Include relevant details to help diagnose issues
- Consider custom exception handlers for specific error types
Versioning Your API
As your API evolves, you'll need a versioning strategy. There are several approaches:
URL Path Versioning
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/v1/users/")
async def get_users_v1():
return {"version": "v1", "data": ["user1", "user2"]}
@app.get("/api/v2/users/")
async def get_users_v2():
return {"version": "v2", "data": [{"id": 1, "name": "user1"}, {"id": 2, "name": "user2"}]}
Using Different Routers for Versions
from fastapi import FastAPI, APIRouter
app = FastAPI()
# Create routers for different API versions
v1_router = APIRouter(prefix="/api/v1", tags=["v1"])
v2_router = APIRouter(prefix="/api/v2", tags=["v2"])
@v1_router.get("/users/")
async def get_users_v1():
return ["user1", "user2"]
@v2_router.get("/users/")
async def get_users_v2():
return [{"id": 1, "name": "user1"}, {"id": 2, "name": "user2"}]
# Include the routers in your app
app.include_router(v1_router)
app.include_router(v2_router)
A Complete Real-World Example
Let's put everything together in a more complete example of a simple blog API:
from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Path, Query
from pydantic import BaseModel, Field
from typing import List, Optional
import datetime
app = FastAPI(
title="Blog API",
description="A simple blog API built with FastAPI",
version="0.1.0"
)
# --- Models ---
class PostBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
content: str = Field(..., min_length=1)
class PostCreate(PostBase):
pass
class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=100)
content: Optional[str] = Field(None, min_length=1)
class PostResponse(PostBase):
id: int
created_at: datetime.datetime
updated_at: datetime.datetime
class Config:
orm_mode = True
# --- Dependencies ---
async def get_pagination(
skip: int = Query(0, ge=0, description="Number of items to skip"),
limit: int = Query(10, ge=1, le=100, description="Number of items to return")
) -> dict:
return {"skip": skip, "limit": limit}
# --- Mock Database ---
# In a real application, you'd use a proper database
posts_db = [
{
"id": 1,
"title": "First Post",
"content": "This is the first post",
"created_at": datetime.datetime.now() - datetime.timedelta(days=2),
"updated_at": datetime.datetime.now() - datetime.timedelta(days=2)
},
{
"id": 2,
"title": "Second Post",
"content": "This is the second post",
"created_at": datetime.datetime.now() - datetime.timedelta(days=1),
"updated_at": datetime.datetime.now() - datetime.timedelta(days=1)
}
]
# --- Router ---
posts_router = APIRouter(prefix="/api/v1/posts", tags=["Posts"])
@posts_router.get("/", response_model=List[PostResponse])
async def get_posts(pagination: dict = Depends(get_pagination)):
skip, limit = pagination["skip"], pagination["limit"]
return posts_db[skip:skip+limit]
@posts_router.get("/{post_id}", response_model=PostResponse)
async def get_post(post_id: int = Path(..., gt=0)):
for post in posts_db:
if post["id"] == post_id:
return post
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with ID {post_id} not found"
)
@posts_router.post("/", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
async def create_post(post: PostCreate):
# Generate a new ID in a real app, this would be handled by the database
new_id = max(p["id"] for p in posts_db) + 1 if posts_db else 1
now = datetime.datetime.now()
new_post = {
"id": new_id,
**post.dict(),
"created_at": now,
"updated_at": now
}
# In a real app, you'd save to a database
posts_db.append(new_post)
return new_post
@posts_router.patch("/{post_id}", response_model=PostResponse)
async def update_post(post_update: PostUpdate, post_id: int = Path(..., gt=0)):
for i, post in enumerate(posts_db):
if post["id"] == post_id:
# Update only provided fields
update_data = post_update.dict(exclude_unset=True)
updated_post = {**post, **update_data, "updated_at": datetime.datetime.now()}
posts_db[i] = updated_post
return updated_post
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with ID {post_id} not found"
)
@posts_router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(post_id: int = Path(..., gt=0)):
for i, post in enumerate(posts_db):
if post["id"] == post_id:
# In a real app, you'd delete from the database
posts_db.pop(i)
return None
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with ID {post_id} not found"
)
# Include the router in the app
app.include_router(posts_router)
To run this example:
uvicorn main:app --reload
You can then access the API documentation at http://127.0.0.1:8000/docs and test the endpoints.
Summary and Best Practices
When designing APIs with FastAPI, keep these best practices in mind:
- Organize your code using APIRouter and a clear project structure
- Use proper HTTP methods for each operation
- Set appropriate status codes for all responses
- Define clear request and response models with Pydantic
- Use the right parameter types (path, query, body) for different data
- Leverage dependency injection for reusable components
- Implement proper error handling with meaningful messages
- Consider API versioning from the start
- Document your API using docstrings and examples
- Validate input data thoroughly using Pydantic features
Additional Resources
To deepen your understanding of FastAPI API design:
- FastAPI Official Documentation
- RESTful API Design Best Practices
- Pydantic Documentation
- HTTP Status Codes
Exercises
- Extend the blog API example to include a user authentication system
- Create an API for a library with books, authors, and borrowers
- Implement filtering, sorting, and searching for the blog posts API
- Add rate limiting to your API endpoints
- Implement a custom logging middleware that records all API requests
By following these principles, you'll create APIs that are robust, maintainable, and developer-friendly.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)