Request Body and Data Validation — In-Depth Practical Guide to Pydantic V2
Data validation is like airport security—every passenger (request) must go through identity verification (type checking), baggage screening (constraint validation), and a review of their declaration (custom validators) before boarding the plane (entering the business logic).
1. What You'll Learn
- Pydantic V2
BaseModel:field_validator/model_validatorreplaces@validatorin V1 Field()Advanced Constraints:gt/lt/pattern/examplesand JSON Schema Generation- Nested Models and Model Combinations: Practical Applications of
Optional,Union, andLiteral model_config:ConfigDictreplacesclass Configandfrom_attributes=Truein the V1 ORM mode- Alice Scenario: Complete PyDantic model design for PriceTracker product price submissions
2. Alice's True Story
(1) Pain Point: Price Data Submission and Validation Are Riddled with Errors
Alice's PriceTracker receives price data submitted by suppliers, requiring that prices be greater than 0, currencies be specified using ISO 4217 three-letter codes, and product names cannot be empty. However, Bob's frontend occasionally sends negative prices or invalid currencies. The custom validation code written in Flask is scattered across 10 different locations, and it overlooks the edge case where "the price is 0," resulting in dirty data with a price of 0 in the database.
(2) A Solution for Declarative Validation in Pydantic V2
Pydantic V2 replaces imperative validation with a declarative model; all constraints are defined within the model, and once defined, they apply globally.
from pydantic import BaseModel, Field, field_validator
class PriceCreate(BaseModel):
product_id: int = Field(gt=0)
price: float = Field(gt=0, description="Price in USD")
currency: str = Field(pattern=r"^[A-Z]{3}$", examples=["USD", "EUR"])
@field_validator("currency")
@classmethod
def validate_currency(cls, v: str) -> str:
if v not in {"USD", "EUR", "GBP", "JPY", "CNY"}:
raise ValueError(f"Unsupported currency: {v}")
return v
(3) Revenue
The price validation code has been consolidated from 10 separate logic blocks into a single model definition; values of 0 and negative prices are automatically filtered out, and currency validation is ensured by a dual-layer approach combining regular expressions and custom validators, eliminating dirty data from the database.
3. Pydantic V2 Data Flow
(1) Full Life Cycle
flowchart TD
A[JSON Request Body] --> B[model_validate]
B --> C[Type Coercion]
C --> D[field_validator]
D --> E[model_validator]
E --> F[Valid Model Instance]
F --> G[model_dump]
G --> H[JSON Response]
F --> I[model_dump_json]
I --> J[JSON String]
| Phase | Method | Description |
|---|---|---|
| Input Parsing | model_validate(data) |
Parse and validate from dict/JSON |
| Type Conversion | Automatic | "42" → 42, "9.99" → 9.99 |
| Field Validation | @field_validator |
Custom Validation for a Single Field |
| Model Validation | @model_validator |
Cross-Field Joint Validation |
| Output Serialization | model_dump() / model_dump_json() |
Convert Model to dict/JSON |
(1) ▶ Example: Pydantic V2 Base Model
from pydantic import BaseModel, Field
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
category: str = Field(max_length=100)
base_price: float = Field(gt=0, description="Base price in USD")
# Parse and validate
data = {"name": "Widget", "category": "electronics", "base_price": 29.99}
product = ProductCreate.model_validate(data)
print(product.model_dump())
Output:
{'name': 'Widget', 'category': 'electronics', 'base_price': 29.99}
4. field_validator and model_validator
(1) V1 → V2 Migration Comparison
flowchart LR
V1[Pydantic V1] --> Migrate[V1 → V2 Migration]
Migrate --> V2[Pydantic V2]
V1 --- A["@validator('field')"]
V2 --- B["@field_validator('field')"]
V1 --- C["@root_validator"]
V2 --- D["@model_validator(mode='after')"]
V1 --- E["class Config:"]
V2 --- F["model_config = ConfigDict(...)"]
V1 --- G[".dict()"]
V2 --- H[".model_dump()"]
| V1 Syntax | V2 Syntax | Description |
|---|---|---|
@validator("field") |
@field_validator("field") |
Field Validator |
@root_validator |
@model_validator(mode="after") |
Model-Level Validation |
class Config: orm_mode = True |
model_config = ConfigDict(from_attributes=True) |
ORM Model |
.dict() |
.model_dump() |
Serialize to dict |
.json() |
.model_dump_json() |
Serialize to JSON |
(1) ▶ Example: field_validator—Single-Field Validation
from pydantic import BaseModel, Field, field_validator
class PriceCreate(BaseModel):
product_id: int = Field(gt=0)
price: float = Field(gt=0)
currency: str = Field(default="USD", max_length=3)
@field_validator("price")
@classmethod
def price_precision(cls, v: float) -> float:
# Round to 2 decimal places
return round(v, 2)
@field_validator("currency")
@classmethod
def valid_currency(cls, v: str) -> str:
allowed = {"USD", "EUR", "GBP", "JPY", "CNY"}
if v not in allowed:
raise ValueError(f"Currency must be one of {allowed}")
return v.upper()
# Test validation
p = PriceCreate(product_id=1, price=9.999, currency="usd")
print(p.model_dump())
Output:
{'product_id': 1, 'price': 10.0, 'currency': 'USD'}
(2) ▶ Example: model_validator cross-field validation
from pydantic import BaseModel, Field, model_validator
class PriceRangeQuery(BaseModel):
min_price: float = Field(ge=0, description="Min price in USD")
max_price: float = Field(ge=0, description="Max price in USD")
@model_validator(mode="after")
def check_range(self):
if self.min_price > self.max_price:
raise ValueError("min_price must be <= max_price")
return self
# Valid
valid = PriceRangeQuery(min_price=10, max_price=100)
print(valid.model_dump())
# Invalid - raises validation error
# PriceRangeQuery(min_price=100, max_price=10)
Output:
{'min_price': 10.0, 'max_price': 100.0}
5. Field() Advanced Constraints and JSON Schema
(1) Field() Parameter Quick Reference
| Parameter | Type | Description | JSON Schema Mapping |
|---|---|---|---|
gt |
Value | Greater than | exclusiveMinimum |
ge |
Value | Greater than or equal to | minimum |
lt |
Value | Less than | exclusiveMaximum |
le |
Value | Less than or equal to | maximum |
min_length |
String | Minimum Length | minLength |
max_length |
String | Maximum Length | maxLength |
pattern |
String | Regular Expression | pattern |
default |
Any | Default | default |
examples |
List | Example Values | examples |
description |
String | Description | description |
alias |
String | Field Alias | Alias Mapping |
(1) ▶ Example: Field Constraints and JSON Schema
from pydantic import BaseModel, Field
class ProductCreate(BaseModel):
name: str = Field(
min_length=1,
max_length=200,
description="Product display name",
examples=["Wireless Mouse", "USB Cable"],
)
sku: str = Field(
pattern=r"^[A-Z]{2}-\d{4,6}$",
description="SKU code: 2 letters + 4-6 digits",
examples=["EL-1234", "CB-567890"],
)
base_price: float = Field(
gt=0,
le=999999.99,
description="Base price in USD",
examples=[9.99, 49.99, 199.99],
)
# View generated JSON Schema
print(ProductCreate.model_json_schema())
Output:
# Execution Successful
6. Nested Models and Model Combinations
(1) Nested Model Structure
(1) ▶ Example: PriceTracker's nested model
from pydantic import BaseModel, Field
from typing import Optional
class PriceInfo(BaseModel):
amount: float = Field(gt=0, description="Price amount in USD")
currency: str = Field(default="USD", pattern=r"^[A-Z]{3}$")
source: str = Field(max_length=100, description="Price source")
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
category: str = Field(max_length=100)
current_price: PriceInfo # Nested model
original_price: Optional[PriceInfo] = None # Optional nested
# Nested validation
data = {
"name": "Wireless Mouse",
"category": "electronics",
"current_price": {"amount": 29.99, "currency": "USD", "source": "Amazon"},
"original_price": {"amount": 49.99, "currency": "USD", "source": "Amazon"},
}
product = ProductCreate.model_validate(data)
print(product.model_dump())
Output:
# Execution Successful
(2) ▶ Example: Union and Literal
from pydantic import BaseModel, Field
from typing import Union, Literal
class SinglePrice(BaseModel):
type: Literal["single"] = "single"
amount: float = Field(gt=0)
class RangePrice(BaseModel):
type: Literal["range"] = "range"
min_amount: float = Field(gt=0)
max_amount: float = Field(gt=0)
class ProductPrice(BaseModel):
product_id: int = Field(gt=0)
pricing: Union[SinglePrice, RangePrice] # Discriminated union
# FastAPI uses "type" field to determine which model to validate
data = {"product_id": 1, "pricing": {"type": "range", "min_amount": 10, "max_amount": 50}}
pp = ProductPrice.model_validate(data)
print(pp.model_dump())
Output:
# Execution Successful
(2) ConfigDict Configuration
(3) ▶ Example: model_config and the ORM pattern
from pydantic import BaseModel, ConfigDict
class ProductResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # Enable ORM mode (read from SQLAlchemy objects)
populate_by_name=True, # Allow both field name and alias
json_schema_extra={
"examples": [{"id": 1, "name": "Widget", "price": 9.99}]
},
)
id: int
name: str
price: float
# With from_attributes=True, can create from object attributes
class FakeORMObject:
def __init__(self):
self.id = 1
self.name = "Widget"
self.price = 9.99
orm_obj = FakeORMObject()
response = ProductResponse.model_validate(orm_obj)
print(response.model_dump())
Output:
{'id': 1, 'name': 'Widget', 'price': 9.99}
7. Comprehensive Example
Pydantic V2's field validation and cross-field validation, combined with the ORM pattern and FastAPI request bodies, enable a complete data input validation workflow.
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
class PriceCreate(BaseModel):
product_name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="The price must be greater than 0")
currency: str = "USD"
@field_validator("currency")
@classmethod
def validate_currency(cls, v: str) -> str:
if v not in ("USD", "EUR", "GBP"):
raise ValueError("currency must be USD/EUR/GBP")
return v
class PriceRangeQuery(BaseModel):
min_price: float = Field(ge=0)
max_price: float = Field(ge=0)
@model_validator(mode="after")
def validate_range(self):
if self.min_price > self.max_price:
raise ValueError("min_price must <= max_price")
return self
app = FastAPI()
@app.post("/prices")
async def create_price(data: PriceCreate):
return data.model_dump()
Output:
POST /prices {"product_name":"Widget","price":9.99} → {"product_name":"Widget","price":9.99,"currency":"USD"}
POST /prices {"product_name":"","price":-1} → 422 Validation Error
❓ FAQ
field_validator and model_validator?field_validator for single-field validation (such as format checks), and use model_validator for cross-field validation (such as min_price <= max_price).from_attributes=True do?Field()'s examples and json_schema_extra?examples is a list of example values for a field, mapped to OpenAPI examples; json_schema_extra is an additional schema property at the model level.📖 Summary
- Pydantic V2 replaces V1's
@validatorand@root_validatorwith@field_validatorand@model_validator Field()Automatically maps constraints to JSON Schema, while driving both OpenAPI documentation and request data validation- The nested model supports flexible combinations of
Optional,Union, andLiteral, whileLiteralimplements a discriminative joint type. ConfigDict(from_attributes=True)Enable ORM mode to create PyDantic models directly from SQLAlchemy objects- V1→V2 Migration Core:
.dict()→.model_dump(),class Config→model_config = ConfigDict(...)
📝 Exercises
- Basic Problem (Difficulty ⭐): Create the
PriceCreatemodel, which includesproduct_id: int(> 0) andprice: float(> 0), and verify that invalid input triggers a validation error. Hint:BaseModel+Field(gt=0) - Advanced Exercise (Difficulty ⭐⭐): Add
PriceCreateto@field_validatorto validate that thecurrencyfield can only contain USD/EUR/GBP, and add@model_validatorto ensuremin_price <= max_price. Hint:field_validator+model_validator(mode="after") - Challenge Problem (Difficulty ⭐⭐⭐): Design a complete nested model system for PriceTracker:
ProductCreatecontainsPriceInfo(amount + currency); the currency inPriceInfois constrained by a regular expression; and the SKU inProductCreateis constrained by the format inpattern. Hint:Field(pattern=...)+ nestedBaseModel
---|



