Building Production-Ready REST APIs with FastAPI and Pydantic
Introduction
FastAPI has quickly become one of the most popular Python frameworks for building APIs, and for good reason. It combines the simplicity of Flask with the power of automatic documentation generation and built-in data validation through Pydantic. In this guide, we'll explore how to build production-ready REST APIs that are both performant and maintainable.
Why FastAPI + Pydantic?
FastAPI offers several advantages over traditional Python web frameworks:
- Automatic API documentation with OpenAPI/Swagger UI
- Type hints everywhere for better code quality and IDE support
- Built-in data validation through Pydantic models
- High performance comparable to Node.js and Go
- Async/await support out of the box
Setting Up Your Project Structure
Let's start with a clean project structure for a task management API:
project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── task.py
│ ├── routers/
│ │ ├── __init__.py
│ │ └── tasks.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── task.py
│ └── database.py
├── requirements.txt
└── README.mdCreating Pydantic Models for Data Validation
Pydantic models serve as both data validators and serializers. Here's how to create robust schemas:
# app/schemas/task.py
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
from enum import Enum
class TaskStatus(str, Enum):
TODO = "todo"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class TaskBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
status: TaskStatus = TaskStatus.TODO
tags: List[str] = Field(default_factory=list, max_items=5)
@validator('tags')
def validate_tags(cls, v):
return [tag.strip().lower() for tag in v if tag.strip()]
class TaskCreate(TaskBase):
pass
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
status: Optional[TaskStatus] = None
tags: Optional[List[str]] = Field(None, max_items=5)
class TaskResponse(TaskBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = TrueBuilding the API Router
Organize your endpoints using APIRouter for better modularity:
# app/routers/tasks.py
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import List, Optional
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse
from app.database import get_db
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(task: TaskCreate, db=Depends(get_db)):
"""Create a new task with validation"""
# Database logic here
return created_task
@router.get("/", response_model=List[TaskResponse])
async def get_tasks(
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"),
status: Optional[str] = Query(None, description="Filter by status"),
db=Depends(get_db)
):
"""Get tasks with pagination and filtering"""
# Database logic here
return tasks
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int, db=Depends(get_db)):
"""Get a specific task by ID"""
task = await find_task_by_id(task_id, db)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(task_id: int, task_update: TaskUpdate, db=Depends(get_db)):
"""Update a task with partial data"""
existing_task = await find_task_by_id(task_id, db)
if not existing_task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.dict(exclude_unset=True)
updated_task = await update_task_in_db(task_id, update_data, db)
return updated_task
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: int, db=Depends(get_db)):
"""Delete a task"""
task = await find_task_by_id(task_id, db)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await delete_task_from_db(task_id, db)
return NoneError Handling and Custom Exceptions
Implement proper error handling for a professional API:
# app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
app = FastAPI(
title="Task Management API",
description="A production-ready task management API",
version="1.0.0"
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": "Validation Error",
"details": exc.errors(),
"message": "Please check your input data"
}
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
return JSONResponse(
status_code=400,
content={
"error": "Bad Request",
"message": str(exc)
}
)
app.include_router(tasks_router)Adding Middleware and CORS
Configure essential middleware for production deployment:
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
import time
# CORS configuration
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"], # Configure properly for production
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
# Security middleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["yourdomain.com", "*.yourdomain.com"]
)
# Custom timing middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return responseTesting Your API
FastAPI makes testing straightforward with its built-in test client:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_task():
response = client.post(
"/api/v1/tasks/",
json={
"title": "Test Task",
"description": "This is a test",
"tags": ["testing", "api"]
}
)
assert response.status_code == 201
assert response.json()["title"] == "Test Task"
def test_get_tasks():
response = client.get("/api/v1/tasks/")
assert response.status_code == 200
assert isinstance(response.json(), list)Best Practices for Production
- Use environment variables for configuration management
- Implement proper logging with structured logs
- Add rate limiting to prevent abuse
- Use database connection pooling for better performance
- Implement caching strategies with Redis where appropriate
- Add health check endpoints for monitoring
Conclusion
FastAPI with Pydantic provides an excellent foundation for building production-ready APIs. The automatic documentation, type safety, and built-in validation make it easier to maintain and scale your applications. Remember to always validate your data, handle errors gracefully, and test your endpoints thoroughly before deployment.
Related Posts
Building Scalable GraphQL APIs with NestJS: A Practical Guide
Learn to create powerful, type-safe GraphQL APIs using NestJS with practical examples and best practices for scalable applications.
Implementing GraphQL with NestJS: A Complete Guide for Modern API Development
Learn how to build scalable GraphQL APIs with NestJS using decorators, resolvers, and type-safe schemas for modern backend development.
Implementing JWT Authentication in Laravel 11: A Complete Guide
Master JWT authentication in Laravel 11 with practical examples, security best practices, and real-world implementation strategies.