Introduction
Welcome to Module 5 of the FastAPI Complete Course. In the previous modules, you learned how to build API endpoints, handle path and query parameters, and structure your application. Now, we dive into one of the most critical aspects of building robust APIs: data validation.
When users send data to your API, you cannot trust it blindly. Invalid data can crash your application, corrupt your database, or create security vulnerabilities. FastAPI, built on top of Pydantic, provides an incredibly powerful and intuitive data validation system that handles all of this for you.
In this chapter, you will learn how to use FastAPI data validation to ensure that your API only accepts clean, correct data. We will cover type checking, string and number validation, email validation, custom validators, and error handling. By the end, you will be able to build APIs that are both developer-friendly and robust.
Type Checking with Pydantic
The foundation of FastAPI data validation is Pydantic. Pydantic is a Python library that uses Python type hints to validate data. FastAPI integrates with Pydantic seamlessly, allowing you to define request bodies, query parameters, and path parameters with automatic validation.
Defining Your First Pydantic Model
Let’s start with a simple example. Imagine you are building an API for a user registration system. You want to ensure that the incoming data has the correct types.
from pydantic import BaseModel
from typing import Optional
class UserCreate(BaseModel):
username: str
email: str
age: int
is_active: bool = True
bio: Optional[str] = None
Explanation:
BaseModelis the base class for all Pydantic models.- Each field has a type hint:
str,int,bool. is_activehas a default value ofTrue, making it optional.biois an optional string. If not provided, it defaults toNone.
Now, let’s use this model in a FastAPI endpoint:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class UserCreate(BaseModel):
username: str
email: str
age: int
is_active: bool = True
bio: Optional[str] = None
@app.post("/users/")
async def create_user(user: UserCreate):
return {
"username": user.username,
"email": user.email,
"age": user.age,
"is_active": user.is_active,
"bio": user.bio
}
When a client sends a POST request to /users/ with a JSON body, FastAPI automatically validates the data. If someone sends {"username": "john", "email": "john@example.com", "age": "twenty"}, FastAPI will return a 422 Unprocessable Entity error because age must be an integer.
Why Type Checking Matters
- Safety: Prevents runtime errors caused by unexpected data types.
- Documentation: The type hints serve as live documentation for your API.
- Auto-completion: IDEs can provide better code completion when types are defined.
String Validation
While basic type checking ensures that a field is a string, you often need more specific constraints. For example, a username should be between 3 and 20 characters, or a password must contain at least one number.
Pydantic provides the Field class from Pydantic’s Field function to add constraints to your fields.
Using Field Constraints for Strings
from pydantic import BaseModel, Field
from typing import Optional
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20, regex="^[a-zA-Z0-9_]+$")
password: str = Field(..., min_length=8, max_length=50)
bio: Optional[str] = Field(None, max_length=500)
Explanation:
Field(...)means the field is required (ellipsis...indicates required).min_length=3andmax_length=20restrict the string length.regex="^[a-zA-Z0-9_]+$"ensures the username contains only letters, numbers, and underscores.biois optional, but if provided, it cannot exceed 500 characters.
Common String Validators
min_length– minimum number of characters.max_length– maximum number of characters.regex– regular expression pattern matching.to_lower– automatically convert to lowercase.strip_whitespace– remove leading/trailing whitespace.
Number Validation
Numbers in APIs often need constraints like minimum or maximum values, or being positive or negative. Pydantic’s Field also supports numeric constraints.
Validating Integers and Floats
from pydantic import BaseModel, Field
from typing import Optional
class ProductCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
price: float = Field(..., gt=0, le=10000)
quantity: int = Field(..., ge=0, le=1000)
discount: Optional[float] = Field(None, ge=0, le=100)
Explanation:
gt=0means greater than 0 (price must be positive).le=10000means less than or equal to 10000.ge=0means greater than or equal to 0 (quantity cannot be negative).discountis optional, but if provided, must be between 0 and 100 (percentage).
Available Number Constraints
gt– greater thange– greater than or equal tolt– less thanle– less than or equal tomultiple_of– must be a multiple of a given number
Email Validation
Email validation is a common requirement, but writing a perfect email regex is notoriously difficult. Pydantic provides a special type called EmailStr that handles this for you.
Using EmailStr
First, you need to install the email-validator library:
pip install pydantic[email]
Now, you can use EmailStr in your models:
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: EmailStr
age: int = Field(..., ge=18, le=120)
Explanation:
EmailStrautomatically validates that the string is a valid email format.- It checks for the presence of
@, a valid domain, and proper structure. - If the email is invalid, FastAPI returns a clear error message.
Why Use EmailStr Instead of Regex?
- Accuracy: The library follows the official email specification (RFC 5321/5322).
- Maintenance: You don’t need to write or maintain complex regex patterns.
- Performance: It’s optimized for validation.
Custom Validators
Sometimes, built-in validators are not enough. For example, you might want to ensure that a username is not already taken, or that a password meets specific security requirements. Pydantic allows you to create custom validators using the @validator decorator.
Creating a Simple Custom Validator
from pydantic import BaseModel, Field, validator
from typing import Optional
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
password: str = Field(..., min_length=8)
confirm_password: str
@validator('confirm_password')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
Explanation:
- The
@validatordecorator takes the field name as an argument. - The method name can be anything, but it’s common to use a descriptive name.
- The first parameter
clsis the class itself. - The second parameter
vis the value being validated. - The third parameter
valuesis a dictionary of all previously validated fields. - If validation fails, raise a
ValueErrorwith a descriptive message.
Validating Multiple Fields
You can also create validators that check multiple fields together:
from pydantic import BaseModel, validator
from datetime import date
class EventCreate(BaseModel):
name: str
start_date: date
end_date: date
@validator('end_date')
def end_date_after_start(cls, v, values):
if 'start_date' in values and v <= values['start_date']:
raise ValueError('End date must be after start date')
return v
Pre-validators and Post-validators
By default, validators run after the type conversion. You can use pre=True to run them before type conversion:
from pydantic import BaseModel, validator
class ProductCreate(BaseModel):
sku: str
@validator('sku', pre=True)
def normalize_sku(cls, v):
if isinstance(v, str):
return v.strip().upper()
return v
This ensures that the SKU is always uppercase and trimmed, regardless of how the client sends it.
Error Handling
FastAPI automatically returns detailed validation errors when data is invalid. However, you can customize how these errors are presented to your users.
Understanding Default Error Responses
When validation fails, FastAPI returns a 422 Unprocessable Entity response with a JSON body like this:
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
},
{
"loc": ["body", "age"],
"msg": "ensure this value is greater than or equal to 18",
"type": "value_error.number.not_ge"
}
]
}
Explanation:
loctells you exactly where the error occurred (body, path, query).msgis a human-readable error message.typeis a machine-readable error type.
Customizing Error Responses
You can override the default validation error handler to change the response format:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": " -> ".join([str(loc) for loc in error["loc"]]),
"message": error["msg"],
"error_code": error["type"]
})
return JSONResponse(
status_code=422,
content={"success": False, "errors": errors}
)
This gives you full control over the error response structure, which is useful for frontend teams or API consumers.
Handling Validation Errors in Business Logic
Sometimes, you need to validate data based on business rules that cannot be expressed with simple constraints. For example, checking if a username is already taken:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
fake_users_db = {"admin", "root", "test"}
class UserCreate(BaseModel):
username: str
@app.post("/users/")
async def create_user(user: UserCreate):
if user.username in fake_users_db:
raise HTTPException(
status_code=400,
detail=f"Username '{user.username}' is already taken"
)
# Proceed with user creation
return {"message": "User created successfully"}
Common Mistakes
- Forgetting to import Field: Many beginners use
Fieldwithout importing it. Always addfrom pydantic import Field. - Using the wrong constraint name: Remember that
gtmeans greater than, not greater than or equal. Usegefor the latter. - Not handling optional fields correctly: Use
Optional[type]fromtypingand set a default ofNone. - Over-validating: Adding too many constraints can make your API inflexible. Only validate what is necessary for data integrity.
- Ignoring error responses: Always test what your API returns when validation fails. It helps frontend developers debug issues.
- Using EmailStr without installing pydantic[email]: You’ll get an import error if the email-validator library is not installed.
Practice Task
Now it’s your turn to apply what you’ve learned. Create a FastAPI endpoint for a book review system.
Requirements:
- Create a Pydantic model called
BookReviewwith the following fields:title: string, required, 1-100 charactersauthor: string, required, 1-50 charactersrating: integer, required, between 1 and 5review_text: string, optional, max 1000 charactersemail: valid email address, required
- Add a custom validator that ensures
ratingis not 3 (because nobody writes reviews for average books). - Create a POST endpoint
/reviews/that accepts this model and returns the validated data. - Test your endpoint with both valid and invalid data using a tool like curl or Postman.
Bonus: Add a custom error handler that returns errors in a format like {"error": "field_name: error_message"}.
Summary
In this module, you learned the essentials of FastAPI data validation:
- Type checking with Pydantic models ensures that your API receives the correct data types.
- String validation using
Fieldconstraints likemin_length,max_length, andregex. - Number validation with constraints such as
gt,ge,lt, andle. - Email validation using the powerful
EmailStrtype. - Custom validators with the
@validatordecorator for complex business rules. - Error handling including customizing the default 422 error responses.
Data validation is not just about preventing errors—it’s about building APIs that are self-documenting, secure, and developer-friendly. With FastAPI and Pydantic, you get all of this with minimal code.
FAQs
1. What is the difference between Field(...) and Field(None)?
Field(...) makes the field required—the client must provide a value. Field(None) makes the field optional, with a default value of None if not provided.
2. Can I use regular Python type hints without Pydantic?
No. FastAPI relies on Pydantic for validation. Simple type hints like str or int are only used for documentation and basic type coercion, not validation.
3. How do I validate nested JSON objects?
You can nest Pydantic models inside each other. For example, a User model can have an address field that is itself a Pydantic model with its own validation.
4. Why does my custom validator not run?
Make sure you have the @validator decorator correctly applied. Also, validators run in the order fields are defined. If your validator depends on another field, ensure that field is defined before the validator.
5. Can I reuse validation logic across multiple models?
Yes! You can create a base model with common validators and inherit from it. You can also define standalone validator functions and use them in multiple models.
Next up: Module 6 – Database Integration with SQLAlchemy. You’ll learn how to connect your validated data to a real database, perform CRUD operations, and handle database relationships. Your solid understanding of data validation will be the foundation for building production-ready APIs. Keep coding!
Additional Practical Example
Let’s build a more realistic data validation scenario that combines multiple Pydantic features you’ve learned. We’ll create an e-commerce product API with nested models, custom validators, and conditional validation.
First, define the models with proper validation:
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field, validator, condecimal, conint
class ProductVariant(BaseModel):
sku: str = Field(..., min_length=5, max_length=20,
description="Stock Keeping Unit code")
name: str = Field(..., min_length=1, max_length=100)
price: condecimal(max_digits=10, decimal_places=2) = Field(..., gt=0)
stock: conint(ge=0) = Field(0, description="Current inventory count")
is_active: bool = True
@validator('sku')
def validate_sku_format(cls, v):
"""Ensure SKU follows pattern: 2 letters + 4 digits + optional suffix"""
import re
if not re.match(r'^[A-Z]{2}d{4}[A-Z0-9]{0,6}$', v):
raise ValueError('SKU must start with 2 uppercase letters '
'followed by 4 digits and optional alphanumeric suffix')
return v
class ProductCategory(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
slug: str = Field(..., regex=r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
parent_category: Optional[str] = None
class Product(BaseModel):
id: Optional[int] = None
name: str = Field(..., min_length=3, max_length=200)
description: str = Field(..., min_length=10, max_length=2000)
category: ProductCategory
variants: List[ProductVariant] = Field(..., min_items=1, max_items=50)
tags: List[str] = Field(default_factory=list, max_length=10)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = None
@validator('tags', each_item=True)
def validate_tag_format(cls, v):
"""Ensure each tag is lowercase and has no spaces"""
if ' ' in v or v != v.lower():
raise ValueError('Tags must be lowercase with no spaces')
return v
@validator('name')
def name_must_be_unique_case_insensitive(cls, v, values):
"""Simulate checking for unique product names (case-insensitive)"""
# In real app, you'd query the database
existing_names = {'laptop', 'phone', 'tablet'}
if v.lower() in existing_names:
raise ValueError(f'Product name "{v}" already exists')
return v
@validator('variants')
def validate_variant_prices_consistency(cls, v):
"""Ensure no variant has a price 50% higher than the cheapest variant"""
if len(v) > 1:
prices = [variant.price for variant in v]
min_price = min(prices)
max_price = max(prices)
if max_price > min_price * Decimal('1.5'):
raise ValueError('No variant can be 50% more expensive than the cheapest variant')
return v
class ProductCreateResponse(BaseModel):
success: bool
product_id: int
message: str = "Product created successfully"
Now let’s create the FastAPI endpoints with proper error handling:
from fastapi import FastAPI, HTTPException, Depends, status
from typing import List
app = FastAPI(title="E-commerce Product API")
# In-memory storage (replace with database in production)
products_db = {}
product_counter = 0
@app.post("/products/", response_model=ProductCreateResponse,
status_code=status.HTTP_201_CREATED)
async def create_product(product: Product):
global product_counter
try:
# Validate the product (Pydantic does this automatically)
product_data = product.dict()
# Simulate database operation
product_counter += 1
product_id = product_counter
product_data['id'] = product_id
products_db[product_id] = product_data
return ProductCreateResponse(
success=True,
product_id=product_id,
message=f"Product '{product.name}' created with {len(product.variants)} variants"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to create product: {str(e)}"
)
@app.get("/products/{product_id}", response_model=Product)
async def get_product(product_id: int):
product = products_db.get(product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return product
@app.get("/products/", response_model=List[Product])
async def list_products(skip: int = 0, limit: int = 10):
product_list = list(products_db.values())
return product_list[skip:skip + limit]
Let’s test the validation with a sample request:
# Example valid product creation
valid_product = {
"name": "Wireless Mouse Pro",
"description": "High-performance wireless mouse with ergonomic design and long battery life.",
"category": {
"name": "Computer Accessories",
"slug": "computer-accessories"
},
"variants": [
{
"sku": "WM00100BLACK",
"name": "Black Edition",
"price": 49.99,
"stock": 100
},
{
"sku": "WM00100WHITE",
"name": "White Edition",
"price": 54.99,
"stock": 75
}
],
"tags": ["wireless", "ergonomic", "mouse"]
}
# Example invalid product (will trigger validation errors)
invalid_product = {
"name": "Phone", # This will fail because "phone" already exists
"description": "Short", # Too short (min 10 chars)
"category": {
"name": "Electronics",
"slug": "Invalid Slug!!!" # Invalid slug format
},
"variants": [
{
"sku": "INVALID", # Doesn't match SKU pattern
"name": "",
"price": -10, # Negative price
"stock": -5 # Negative stock
}
],
"tags": ["Invalid Tag With Spaces"] # Invalid tag format
}
This practical example demonstrates:
- Nested models with Product containing Category and Variants
- Field constraints using min_length, max_length, regex, and decimal precision
- Custom validators for SKU format, tag formatting, and business rules
- Conditional validation checking variant price consistency
- Error handling with proper HTTP status codes and messages
Class-Based Implementation Example
While FastAPI works excellently with functions, class-based views can help organize related endpoints and share common dependencies. Here’s how to implement data validation using class-based approach:
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Optional
from pydantic import BaseModel, Field, validator
# Shared validation models
class UserBase(BaseModel):
username: str = Field(..., min_length=3, max_length=50, regex=r'^[a-zA-Z0-9_]+$')
email: str = Field(..., regex=r'^[w.-]+@[w.-]+.w+$')
@validator('username')
def username_no_special_chars(cls, v):
if not v.isalnum() and '_' not in v:
raise ValueError('Username can only contain letters, numbers, and underscores')
return v
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=100)
confirm_password: str
@validator('confirm_password')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
@validator('password')
def password_strength(cls, v):
"""Check password has at least one uppercase, one lowercase, and one digit"""
if not any(c.isupper() for c in v):
raise ValueError('Password must contain at least one uppercase letter')
if not any(c.islower() for c in v):
raise ValueError('Password must contain at least one lowercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain at least one digit')
return v
class UserUpdate(BaseModel):
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[str] = Field(None, regex=r'^[w.-]+@[w.-]+.w+$')
bio: Optional[str] = Field(None, max_length=500)
class UserResponse(UserBase):
id: int
is_active: bool = True
created_at: str
# Class-based router
class UserRouter:
def __init__(self):
self.router = APIRouter(prefix="/users", tags=["users"])
self.users_db: Dict[int, dict] = {}
self.counter = 0
# Register routes
self.router.add_api_route("/", self.create_user, methods=["POST"],
response_model=UserResponse, status_code=201)
self.router.add_api_route("/{user_id}", self.get_user, methods=["GET"],
response_model=UserResponse)
self.router.add_api_route("/{user_id}", self.update_user, methods=["PATCH"],
response_model=UserResponse)
self.router.add_api_route("/{user_id}", self.delete_user, methods=["DELETE"],
status_code=204)
async def create_user(self, user: UserCreate):
"""Create a new user with full validation"""
self.counter += 1
user_id = self.counter
# Simulate email uniqueness check
for existing_user in self.users_db.values():
if existing_user['email'] == user.email:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered"
)
user_data = user.dict(exclude={'confirm_password'})
user_data['id'] = user_id
user_data['is_active'] = True
user_data['created_at'] = "2024-01-15T10:30:00Z"
# Hash password (in real app)
user_data['hashed_password'] = f"hashed_{user.password}"
del user_data['password']
self.users_db[user_id] = user_data
return UserResponse(**user_data)
async def get_user(self, user_id: int):
"""Retrieve a user by ID"""
user = self.users_db.get(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found"
)
return UserResponse(**user)
async def update_user(self, user_id: int, user_update: UserUpdate):
"""Partially update user information"""
if user_id not in self.users_db:
raise HTTPException(status_code=404, detail="User not found")
# Only update provided fields
update_data = user_update.dict(exclude_unset=True)
# Validate email uniqueness if changing
if 'email' in update_data:
for uid, existing in self.users_db.items():
if uid != user_id and existing['email'] == update_data['email']:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already in use"
)
self.users_db[user_id].update(update_data)
return UserResponse(**self.users_db[user_id])
async def delete_user(self, user_id: int):
"""Delete a user"""
if user_id not in self.users_db:
raise HTTPException(status_code=404, detail="User not found")
del self.users_db[user_id]
# Register the router
user_router = UserRouter()
app.include_router(user_router.router)
Class-based implementation advantages:
- State management – Shared database connection or configuration across endpoints
- Code organization – Related endpoints grouped logically
- Reusable validation – Models defined once, used across methods
- Easier testing – Can instantiate the class with mock dependencies
Hands-On Practice Task
Now it’s your turn to apply what you’ve learned. Build a blog post API with the following requirements:
Task: Create a Blog Post Management System
Implement the following endpoints:
POST /posts/– Create a new blog post with validationGET /posts/{post_id}– Retrieve a specific postPATCH /posts/{post_id}– Update post fields (partial update)DELETE /posts/{post_id}– Delete a post
Validation Requirements:
- Title: 5-100 characters, must start with a capital letter
- Content: 50-10000 characters, cannot be HTML (strip tags validation)
- Author: Must be a valid email format
- Tags: List of 1-5 tags, each 2-20 lowercase characters with no spaces
- Status: Must be one of: “draft”, “published”, “archived”
- Published date: Optional, but if status is “published”, it’s required
- Slug: Auto-generated from title (lowercase, hyphens instead of spaces), must be unique
Starter Code:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import datetime
import re
app = FastAPI(title="Blog Post API")
# TODO: Define your Pydantic models here
class PostCreate(BaseModel):
# Add fields with proper validation
pass
class PostUpdate(BaseModel):
# Add fields for partial update
pass
class PostResponse(BaseModel):
# Define response model
pass
# TODO: Implement your endpoints
# @app.post("/posts/")
# async def create_post(post: PostCreate):
# pass
# @app.get("/posts/{post_id}")
# async def get_post(post_id: int):
# pass
# @app.patch("/posts/{post_id}")
# async def update_post(post_id: int, post: PostUpdate):
# pass
# @app.delete("/posts/{post_id}")
# async def delete_post(post_id: int):
# pass
Expected Behavior:
- Valid posts should return 201 status with the created post data
- Invalid data should return 422 with clear error messages
- Attempting to publish without a date should fail
- Duplicate slugs should return 409 Conflict
- Non-existent posts should return 404
Bonus Challenges:
- Add a custom validator that checks for profanity in title and content
- Implement pagination for listing all posts
- Add a search endpoint that filters by title, content, or tags
- Create a class-based version of your router
Test your implementation with these sample requests:
# Valid post creation
valid_post = {
"title": "Getting Started with FastAPI",
"content": "FastAPI is a modern web framework for building APIs with Python...",
"author": "author@example.com",
"tags": ["fastapi", "python", "tutorial"],
"status": "draft"
}
# Invalid post (should fail validation)
invalid_post = {
"title": "short", # Too short
"content": "Too short content", # Too short
"author": "not-an-email", # Invalid email
"tags": ["INVALID TAG WITH SPACES"], # Invalid tag format
"status": "unknown_status" # Invalid status
}
Common Interview Questions
Here are frequently asked interview questions about data validation in FastAPI, with detailed explanations:
Q1: What’s the difference between Pydantic’s Field and validator?
A: Field is used for declarative validation – defining constraints like minimum length, maximum value, regex patterns, and default values. It’s best for simple, static validation rules. validator is used for imperative validation – complex logic that might depend on multiple fields, external data, or require computation. Use Field for 80% of cases and validator for the remaining 20% where you need custom logic.
Q2: How do you handle optional fields with conditional validation?
A: Use Optional[type] for the field type and set a default of None. Then use @validator with pre=True to check conditions before other validation runs. For example, requiring a published_date only when status is “published”:
class Post(BaseModel):
status: str
published_date: Optional[datetime] = None
@validator('published_date', always=True)
def validate_published_date(cls, v, values):
if values.get('status') == 'published' and v is None:
raise ValueError('Published date required when status is published')
return v
Q3: How does FastAPI handle validation errors and return them to the client?
A: FastAPI automatically catches Pydantic validation errors and returns a 422 Unprocessable Entity response with a structured JSON body containing details about each validation failure. The response includes the field name, error message, and the invalid value. You can customize this behavior by creating a custom exception handler:
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"detail": exc.errors(),
"body": exc.body,
"custom_message": "Validation failed. Please check your input."
}
)
Q4: What’s the purpose of response_model in endpoint decorators?
A: response_model serves multiple purposes: it validates the response data before sending it to the client, filters out fields not defined in the model, automatically converts types (e.g., datetime to string), and provides OpenAPI documentation. It’s a powerful tool for ensuring API consistency and security by preventing accidental data leaks.
Q5: How do you implement custom error messages for validation?
A: Custom error messages can be implemented in several ways:
- In
Fieldusing thedescriptionparameter for documentation - In
validatorusing customValueErrormessages - Using Pydantic’s
ValidationErrorsubclassing - Creating custom exception handlers for specific error types
Example with custom messages:
class UserCreate(BaseModel):
age: int = Field(..., ge=18, description="User must be at least 18 years old")
@validator('age')
def validate_age(cls, v):
if v 120:
raise ValueError('Please enter a valid age')
return v
Q6: Can you explain the difference between BaseModel and dataclass in Python?
A: Both are used for data containers, but Pydantic’s BaseModel offers built-in validation, type coercion, JSON serialization, and integration with FastAPI. Python’s dataclass is lighter but lacks validation features. For FastAPI applications, always use BaseModel (or TypeAdapter for simpler cases) to leverage automatic request validation and documentation generation.
Q7: How do you handle file uploads with validation?
A: Use FastAPI’s UploadFile and File classes, then validate file properties like size, type, and extension in your endpoint logic:
from fastapi import UploadFile, File, HTTPException
@app.post("/upload/")
async def upload_file(file: UploadFile = File(...)):
# Validate file size (e.g., max 5MB)
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(400, "File too large")
# Validate file type
allowed_types = ["image/jpeg", "image/png", "application/pdf"]
if file.content_type not in allowed_types:
raise HTTPException(400, "Invalid file type")
# Validate file extension
import os
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.pdf']:
raise HTTPException(400, "Invalid file extension")
return {"filename": file.filename, "size": len(contents)}
Q8: What are the best practices for organizing validation logic in large FastAPI projects?
A: Follow these practices:
- Create separate modules for models (e.g.,
models/user.py,models/product.py) - Use mixin classes for reusable validation logic
- Keep validators focused – one validator per concern
- Use
pre=Truevalidators for data transformation before validation - Document validation rules with clear error messages
- Consider using
constr,conint,condecimalfor common patterns - Test validation logic separately from endpoint logic
Remember: Good validation is your API’s first line of defense against incorrect data. Invest time in designing comprehensive validation rules that catch errors early and provide helpful feedback to API consumers.
