Pydantic Data Validation and OpenAPI
In the previous chapter, we learned how to create a simple GET route. However, in real-world business applications, we most often deal with JSON data sent from clients (e.g., registration forms, order creation, or sending text to AI for processing).
This is where HTTP POST requests come into play.
In the past, handling POST requests in Flask required manually extracting fields from request.json and writing lengthy if / else checks to validate formats:
"Did they fill in the name?", "Is the email format correct?", "Is the age greater than 0?".
In FastAPI, all these pains are solved by Pydantic.
1. What is Pydantic?
Pydantic is a data validation library for Python. Its core concept is: Using Python's native type hints to declare data structures.
You only need to define a "Model," and FastAPI will handle everything behind the scenes:
- Automatic Validation: If the incoming data doesn't match the model, it automatically returns a 422 error (with detailed error messages).
- Automatic Conversion: If the incoming data is a string
"123"but the model specifies anint, it will automatically convert it. - Automatic Documentation: Syncs this model to Swagger UI, so frontend developers know what format to send.
2. Creating Your First Pydantic Model
Let's create an "Add Member" API.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
# 1. Declare a Pydantic model
class UserCreate(BaseModel):
username: str
email: str
age: int
is_active: bool = True # Provide a default value
# 2. Use this model as a parameter in the route
@app.post("/users/")
async def create_user(user: UserCreate):
# At this point, the incoming user is already a strictly validated object!
print(f"New user received: {user.username}, email: {user.email}")
# Simulate writing to a database
user_dict = user.model_dump() # Convert the model to a dictionary
user_dict["user_id"] = 9527
return {"message": "Member created successfully", "data": user_dict}
Testing This API
Now, open your http://127.0.0.1:8000/docs. You'll notice:
- A new
POST /users/route appears. - Clicking on it clearly shows the Request Body requires
username,email,age, andis_active. - Click "Try it out." If you intentionally omit
ageor setageto a string like"twenty", the API will immediately block you and return a highly detailed JSON error message, telling you exactly which field is wrong.
This is why FastAPI development is so fast! You no longer need to write defensive logic!
3. Advanced Validation: Using Field()
Sometimes, simply declaring a type (e.g., str or int) isn't enough.
We might want passwords to be at least 8 characters long, ages to be between 18 and 100, or product prices to be non-negative.
This is where Field comes in for deep validation:
from pydantic import BaseModel, Field, EmailStr
class ProductCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=50, description="Product name")
price: float = Field(..., gt=0, description="Product price, must be greater than 0")
discount: float | None = Field(default=None, ge=0, le=1, description="Discount rate (0.0 ~ 1.0)")
tags: list[str] = Field(default_factory=list, max_items=5)
Breakdown:
Field(...): The...indicates this field is required.gt=0: Greater Than, must be greater than 0.ge=0, le=1: Greater or Equal to 0, Less or Equal to 1 (for 0% to 100% discounts).description: This description is directly rendered in Swagger API docs, making it crystal clear for frontend developers!
[!TIP] Email Validation Made Easy If you need to validate email formats, you don't need to write complex regex. Just install the
email-validatorpackage (pip install email-validator), then declare the type asEmailStr. Pydantic will automatically check if the format is correct!
4. Nested Models
In real-world APIs, JSON often has multiple layers. Pydantic handles this effortlesslyโjust nest models within models:
from pydantic import BaseModel
class Address(BaseModel):
city: str
zip_code: str
class UserProfile(BaseModel):
name: str
address: Address # Use another model directly as the type
@app.post("/profile/")
async def update_profile(profile: UserProfile):
return {"city": profile.address.city}
5. Summary
Pydantic acts as the "ultimate gatekeeper" for your backend API. All dirty, incorrect, or malicious data is blocked by Pydantic before it reaches your business logic.
Once data purity is ensured, the next step is to actually "write it to disk." In the next chapter, we'll introduce Python's most powerful ORM systemโSQLAlchemyโand teach you how to seamlessly integrate with relational databases like PostgreSQL!
Field Types and Validation
Pydantic supports a wide range of field types with built-in validation.
Basic Field Types
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserProfile(BaseModel):
id: int
username: str
email: str
age: Optional[int] = None
is_active: bool = True
score: float = 0.0
created_at: datetime
tags: list[str] = []
String Constraints
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str = Field("", max_length=1000)
sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$")
# Valid: "ABC-1234"
# Invalid: "abc-123" (wrong format)
Numeric Constraints
class OrderItem(BaseModel):
quantity: int = Field(..., ge=1, le=100) # 1-100
price: float = Field(..., gt=0, le=9999.99) # > 0, max 9999.99
discount: float = Field(0.0, ge=0, le=1.0) # 0% - 100%
Custom Validators
Use @field_validator for custom validation logic.
Single Field Validation
from pydantic import BaseModel, field_validator
import re
class UserCreate(BaseModel):
username: str
password: str
confirm_password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError("Username can only contain letters, numbers, underscore")
return v
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain an uppercase letter")
if not re.search(r"[0-9]", v):
raise ValueError("Password must contain a number")
return v
Model-Level Validation
from pydantic import BaseModel, model_validator
class UserCreate(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def check_passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
Nested Models
Models can contain other models for complex data structures.
from pydantic import BaseModel
from typing import list
class Address(BaseModel):
street: str
city: str
country: str
postal_code: str
class OrderItem(BaseModel):
product_id: int
quantity: int
class Order(BaseModel):
id: int
customer: str
shipping_address: Address
items: list[OrderItem]
total: float
# API request body
app.post("/orders")
async def create_order(order: Order):
# order.shipping_address.city
# order.items[0].product_id
return order
Error Handling
from fastapi import FastAPI, status
from pydantic import BaseModel
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
errors = []
for error in exc.errors():
errors.append({
"field": " โ ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": errors}
)
Summary
Pydantic provides automatic request validation in FastAPI. It converts incoming JSON to typed Python objects and rejects invalid data with clear error messages.
Key takeaways:
- Pydantic models define the shape of request/response data |
- Field constraints: min_length, max_length, ge, le, pattern regex |
@field_validatorvalidates individual fields with custom logic |@model_validatorvalidates relationships between fields |- Nested models handle complex, hierarchical data structures |
- FastAPI automatically returns 422 for validation failures |
- Custom exception handlers improve API error responses |
- Type hints eliminate manual data parsing and checking |
What's Next: SQLAlchemy Database
The next chapter covers SQLAlchemy integration for database operations.