FastAPI, a modern, high-performance web framework for building APIs with Python, leverages Pydantic for robust data validation and serialization. Pydantic’s powerful type-hinting and validation capabilities make it an ideal choice for ensuring data integrity in FastAPI applications. While basic validations like type checking and required fields are straightforward, custom validations allow developers to enforce complex business rules and constraints. In this article, we’ll explore how to implement custom validations in FastAPI using Pydantic.
Introduction to FastAPI and Pydantic
FastAPI is built on top of Starlette and Pydantic, combining asynchronous programming with automatic data validation and OpenAPI documentation. Pydantic, a data validation library, uses Python type annotations to define data models and enforce validation rules. It integrates seamlessly with FastAPI, enabling developers to define request and response models with built-in validation.
While Pydantic provides out-of-the-box validations for common scenarios (e.g., string length, number ranges, or email formats), advanced use cases often require custom validation logic. For example, you might need to validate that a user’s input adheres to specific business rules, such as ensuring a date range is valid or that a username is unique in a database.
In this article, we’ll cover:
- Setting up a FastAPI project with Pydantic
- Creating custom validators with Pydantic
- Using @model_validator for cross-field validations
- Implementing custom validation classes
- Handling complex business logic with external dependencies
- Best practices and error handling
Setting Up a FastAPI Project with Pydantic
Before diving into custom validations, let’s set up a basic FastAPI project. Ensure you have Python 3.7+ installed, then install the required dependencies:
pip install fastapi uvicorn pydantic
Here’s a minimal FastAPI application to get started:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
quantity: int
@app.post("/items/")
async def create_item(item: Item):
return {"item": item.dict()}
Creating Custom Validators with Pydantic
Pydantic allows you to define custom validators using the @validator decorator. This is useful for validating individual fields with custom logic. Let’s extend the Item model to enforce that the price is positive and the name is not a reserved keyword:
from fastapi import FastAPI
from pydantic import BaseModel, validator
app = FastAPI()
class Item(BaseModel):
name: str
price: float
quantity: int
@validator("price")
def price_must_be_positive(cls, value):
if value <= 0:
raise ValueError("Price must be greater than zero")
return value
@validator("name")
def name_not_reserved(cls, value):
reserved_names = ["admin", "root", "system"]
if value.lower() in reserved_names:
raise ValueError(f"Name '{value}' is reserved and cannot be used")
return value
@app.post("/items/")
async def create_item(item: Item):
return {"item": item.dict()}
In this example:
- The @validator(“price”) ensures the price field is positive.
- The @validator(“name”) checks that the name is not in a list of reserved keywords.
- If validation fails, Pydantic raises a ValueError, which FastAPI converts into a 422 Unprocessable Entity response with a detailed error message.
Try sending a request with curl or Postman:
{
"name": "admin",
"price": -10,
"quantity": 5
}
The response will indicate validation errors for both name and price.
Cross-Field Validations with @model_validator
Sometimes, validation logic depends on multiple fields. For example, you might want to ensure that the quantity is provided only if the price is above a certain threshold. Pydantic’s @model_validator allows you to validate the entire model.
Here’s an example:
from fastapi import FastAPI
from pydantic import BaseModel, model_validator, validator
app = FastAPI()
class Item(BaseModel):
name: str
price: float
quantity: int
@validator("price")
def price_must_be_positive(cls, value):
if value <= 0:
raise ValueError("Price must be greater than zero")
return value
@model_validator(mode="after")
def check_quantity(self):
if self.price > 100 and self.quantity < 10:
raise ValueError(
"Quantity must be at least 10 if price is greater than 100"
)
return self
@app.post("/items/")
async def create_item(item: Item):
return {"item": item.dict()}
In this example:
- The @model_validator checks if the price exceeds 100 and ensures quantity is greater than 10 is such cases.
- The ‘self’ contains all fields.
- The validator returns ‘self’.
This approach is powerful for enforcing complex business rules that involve multiple fields.
Implementing Custom Validation Classes
For reusable or complex validation logic, you can create custom Pydantic types. Suppose you want to validate that a field contains a valid ISBN-13 number. You can define a custom type with its own validation logic.
import re
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, ValidationInfo
app = FastAPI()
class ISBN13(str):
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, value: Any, info: ValidationInfo) -> str:
if not isinstance(value, str):
raise ValueError("ISBN must be a string")
isbn = value.replace("-", "").replace(" ", "")
if not re.match(r"^\d{13}$", isbn):
raise ValueError("ISBN must be a 13-digit number")
# Checksum validation
total = sum(int(isbn[i]) * (1 if i % 2 == 0 else 3) for i in range(12))
checksum = (10 - (total % 10)) % 10
if int(isbn[12]) != checksum:
raise ValueError("Invalid ISBN-13 checksum")
return value
class Book(BaseModel):
title: str
isbn: ISBN13
@app.post("/books/")
async def create_book(book: Book):
return {"book": book.dict()}
To test, after running the project, execute this command:
curl -X POST http://localhost:8000/books/ \
-H "Content-Type: application/json" \
-d '{
"title": "Broken Book",
"isbn": "978-3-16-148410-9"
}'
In this example:
- The ISBN13 class inherits from str and implements custom validation logic.
- The __get_validators__ method registers the validate method as a Pydantic validator.
- The validate method checks the ISBN-13 format and checksum.
- The Book model uses ISBN13 as a type for the isbn field.
This approach is ideal for reusable validations that can be applied across multiple models.
Best Practices for Custom Validations
- Keep Validators Focused: Each validator should handle a specific rule to maintain clarity and reusability.
- Use Descriptive Error Messages: Clear error messages help API consumers understand what went wrong.
- Leverage @model_validator for Cross-Field Logic: Use @model_validator when validation depends on multiple fields to avoid redundant checks.
- Handle Edge Cases: Account for None values, type mismatches, and unexpected inputs in your validators.
- Reuse Custom Types: Create custom Pydantic types for reusable validations like ISBN, phone numbers, or custom formats.
- Test Validators Thoroughly: Write unit tests for your Pydantic models to ensure validation logic works as expected.
Conclusion
Custom validations in FastAPI using Pydantic enable developers to enforce complex business rules with ease. By leveraging @validator, @model_validator, custom types, and dependency injection, you can build robust APIs that ensure data integrity. Whether you’re validating individual fields, cross-field relationships, or external dependencies, Pydantic’s flexibility makes it a powerful tool for FastAPI applications.
By following the best practices outlined in this article, you can create maintainable, reusable, and error-resistant validation logic. Experiment with these techniques in your FastAPI projects to build APIs that are both powerful and reliable.
For further exploration, check out the FastAPI documentation and Pydantic documentation for more advanced features and use cases.
Leave a Reply