Skip to main content

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:

python
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:

python
@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 data
  • POST for creating new resources
  • PUT for completely replacing resources
  • PATCH for partially updating resources
  • DELETE for removing resources

Setting Appropriate Status Codes

Always set the right status code for your responses:

python
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, PATCH
  • 201 Created - Successful POST that creates a resource
  • 204 No Content - Successful operation with no response body (like DELETE)
  • 400 Bad Request - Invalid input
  • 401 Unauthorized - Authentication required
  • 403 Forbidden - Permission denied
  • 404 Not Found - Resource doesn't exist
  • 422 Unprocessable Entity - Validation error

Request and Response Models

Define Clear Pydantic Models

Use Pydantic models to define your request and response schemas:

python
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:

python
@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:

python
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:

python
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:

python
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

python
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

python
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:

python
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:

bash
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:

  1. Organize your code using APIRouter and a clear project structure
  2. Use proper HTTP methods for each operation
  3. Set appropriate status codes for all responses
  4. Define clear request and response models with Pydantic
  5. Use the right parameter types (path, query, body) for different data
  6. Leverage dependency injection for reusable components
  7. Implement proper error handling with meaningful messages
  8. Consider API versioning from the start
  9. Document your API using docstrings and examples
  10. Validate input data thoroughly using Pydantic features

Additional Resources

To deepen your understanding of FastAPI API design:

Exercises

  1. Extend the blog API example to include a user authentication system
  2. Create an API for a library with books, authors, and borrowers
  3. Implement filtering, sorting, and searching for the blog posts API
  4. Add rate limiting to your API endpoints
  5. 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! :)