Pydantic
Pydantic is the data validation and modeling layer at the boundary of every AI system we build. It validates API inputs, structures LLM outputs, defines agent state, and serializes data between services β all with a single, consistent API.
This article covers Pydantic v2 exclusively. The v2 API is a ground-up rewrite in Rust and is not backward-compatible with v1. If you see validator (singular) or dict() methods in code, you are looking at v1.
Why Pydanticβ
Validation at the Boundaryβ
In AI engineering, untrusted data flows everywhere:
- API inputs β user queries, configuration, tool arguments arrive as JSON and must be validated before processing.
- Tool outputs β LLM tool calls return strings that need parsing and validation before your code trusts them.
- Agent state β LangGraph checkpoints serialize and deserialize state through Pydantic models.
- Search results β Azure AI Search returns semi-structured data that must conform to a schema before downstream use.
Pydantic enforces these boundaries. Without it, you write manual validation logic that drifts, duplicates, and breaks silently.
Serialization Out of the Boxβ
Pydantic models convert to and from dictionaries, JSON, and custom formats with zero boilerplate. .model_dump(), .model_validate(), and .model_dump_json() handle the round-trip between Python objects and wire formats.
Ecosystem Integrationβ
Pydantic is the shared data layer across our stack:
| Framework | How Pydantic Fits |
|---|---|
| FastAPI | Request/response bodies, query parameters, and path parameters are Pydantic models |
| LangChain | with_structured_output() returns Pydantic models; tool schemas are Pydantic |
| LangGraph | Agent state is defined as a Pydantic BaseModel; checkpoints serialize through it |
| Azure AI Search | Result parsing uses Pydantic to validate document schemas |
Pydantic vs. Alternativesβ
Why not dataclasses, TypedDict, or attrs? Here is the comparison:
| Feature | Pydantic BaseModel | dataclasses | TypedDict | attrs |
|---|---|---|---|---|
| Runtime validation | Yes β automatic | No | No | No (without validators) |
| Nested validation | Yes β recursive | No | No | Manual |
| Serialization | .model_dump(), .model_dump_json() | Manual (asdict) | Manual | Manual |
| Deserialization | .model_validate() from dict/JSON | Manual | Manual | Manual |
| Custom validators | @field_validator, @model_validator | No | No | @validator (limited) |
| Type coercion | Yes ("42" β 42) | No | No | No |
| Default values | Yes | Yes | Yes | Yes |
| Immutability | frozen=True | frozen=True | No | frozen=True |
| Performance | Rust-backed (fast) | Pure Python | Pure Python | Pure Python |
| AI ecosystem fit | First-class | Limited | Limited | Limited |
For AI engineering, Pydantic is the only option that gives you validation, serialization, and ecosystem integration in one package.
Basic Modelsβ
Defining a Modelβ
Every Pydantic model inherits from BaseModel. You declare fields as class attributes with type annotations:
from pydantic import BaseModel
class UserInput(BaseModel):
query: str
max_results: int = 5
language: str = "en"
Instantiation triggers validation:
# β
Valid β all fields present
input = UserInput(query="Python async patterns", max_results=10)
# β
Valid β defaults fill in missing fields
input = UserInput(query="LangChain agents")
# β ValidationError β type mismatch
# input = UserInput(query="test", max_results="ten")
Field Types and Validationβ
Pydantic validates types at instantiation time. It also coerces where safe:
from pydantic import BaseModel
class SearchConfig(BaseModel):
query: str
max_tokens: int
temperature: float
top_k: int
# Coercion works for compatible types
config = SearchConfig(
query="AI engineering",
max_tokens="1024", # str β int (coerced)
temperature=0.7,
top_k=5,
)
print(config.max_tokens) # 1024 (int, not str)
# Incompatible types raise ValidationError
# config = SearchConfig(query="test", max_tokens="abc", temperature=0.7, top_k=5)
# β ValidationError: 1 validation error for SearchConfig
# max_tokens
# Input should be a valid integer, unable to parse string as an integer
Field Constraintsβ
Use Field() to add constraints beyond type:
from pydantic import BaseModel, Field
class LLMConfig(BaseModel):
model_name: str = Field(
min_length=1,
max_length=128,
pattern=r"^(gpt-|claude-|o[1-3])",
description="The model identifier to use for generation",
)
temperature: float = Field(ge=0.0, le=2.0, default=0.7)
max_tokens: int = Field(gt=0, le=128_000, default=4096)
stop_sequences: list[str] = Field(default_factory=list)
# β
Valid
config = LLMConfig(model_name="gpt-4o", temperature=0.3, max_tokens=2048)
# β ValidationError β temperature out of range
# config = LLMConfig(model_name="gpt-4o", temperature=2.5)
# β ValidationError β model_name does not match pattern
# config = LLMConfig(model_name="custom-model-xyz")
# β ValidationError β max_tokens must be > 0
# config = LLMConfig(model_name="gpt-4o", max_tokens=0)
Default Values and Optional Fieldsβ
from pydantic import BaseModel, Field
from typing import Optional
class ToolCall(BaseModel):
tool_name: str
arguments: dict[str, object]
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
metadata: Optional[dict[str, str]] = None
# Optional field omitted β defaults to None
call = ToolCall(tool_name="search", arguments={"query": "Python"})
print(call.metadata) # None
# Optional field provided
call = ToolCall(
tool_name="search",
arguments={"query": "Python"},
metadata={"source": "user_request"},
)
Do and Don'tβ
from pydantic import BaseModel, Field
# β
DO β explicit types, constraints, and defaults
class QueryRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
max_results: int = Field(default=5, ge=1, le=50)
include_metadata: bool = False
# β DON'T β no validation, no constraints
class QueryRequestWeak(BaseModel):
query: str
max_results: int
include_metadata: bool
# β
DO β use Optional for truly optional fields
class FilterOptions(BaseModel):
date_range: Optional[tuple[str, str]] = None
categories: list[str] = Field(default_factory=list)
# β DON'T β mutable default values as field defaults
class BadModel(BaseModel):
tags: list[str] = [] # Dangerous β shared mutable default
# β
DO β use default_factory for mutable defaults
class GoodModel(BaseModel):
tags: list[str] = Field(default_factory=list)
Never use mutable objects (list, dict, set) as default values directly. Use Field(default_factory=list) or Field(default_factory=dict) instead. While Pydantic handles this more gracefully than plain Python, it is still a source of subtle bugs.
Nested Modelsβ
Nested Model Definitionsβ
Models can contain other models as fields. Validation is recursive:
from pydantic import BaseModel, Field
class SearchResult(BaseModel):
title: str
url: str
snippet: str = Field(max_length=500)
relevance_score: float = Field(ge=0.0, le=1.0)
class SearchResponse(BaseModel):
query: str
results: list[SearchResult]
total_count: int = Field(ge=0)
processing_time_ms: float = Field(gt=0)
response = SearchResponse(
query="Pydantic validation",
results=[
SearchResult(
title="Pydantic v2 Docs",
url="https://docs.pydantic.dev",
snippet="Data validation using Python type annotations.",
relevance_score=0.95,
),
],
total_count=1,
processing_time_ms=42.5,
)
Lists of Modelsβ
Lists of nested models are common in AI workflows β search results, tool outputs, conversation turns:
from pydantic import BaseModel, Field
from typing import Literal
class ChatMessage(BaseModel):
role: Literal["human", "ai", "tool"]
content: str
tool_call_id: str | None = None
class ConversationState(BaseModel):
messages: list[ChatMessage] = Field(default_factory=list)
turn_count: int = Field(default=0, ge=0)
is_complete: bool = False
state = ConversationState(
messages=[
ChatMessage(role="human", content="Search for Python best practices"),
ChatMessage(role="ai", content="I'll search for that."),
ChatMessage(role="tool", content="Found 12 results", tool_call_id="call_abc123"),
],
turn_count=3,
)
Discriminated Unionsβ
When a field can be one of several types, use Union with a discriminator to tell Pydantic which type to validate against:
from pydantic import BaseModel, Field
from typing import Annotated, Literal
class WebSearchResult(BaseModel):
type: Literal["web"]
title: str
url: str
snippet: str
class KnowledgeBaseResult(BaseModel):
type: Literal["knowledge_base"]
document_id: str
content: str
source: str
SearchResult = Annotated[
WebSearchResult | KnowledgeBaseResult,
Field(discriminator="type"),
]
class HybridSearchResponse(BaseModel):
query: str
results: list[SearchResult]
response = HybridSearchResponse(
query="Python async patterns",
results=[
WebSearchResult(
type="web",
title="AsyncIO Documentation",
url="https://docs.python.org/3/library/asyncio.html",
snippet="Asynchronous I/O support.",
),
KnowledgeBaseResult(
type="knowledge_base",
document_id="doc_42",
content="Use asyncio.gather for parallel calls.",
source="internal_wiki",
),
],
)
Discriminated unions are ideal for representing heterogeneous tool outputs, multi-modal search results, or different agent step types. The type discriminator field tells Pydantic which model to validate against without guessing.
Validationβ
Field Validatorsβ
@field_validator runs custom validation logic on individual fields:
from pydantic import BaseModel, Field, field_validator
class LLMParameters(BaseModel):
model_name: str
temperature: float = Field(ge=0.0, le=2.0)
max_tokens: int = Field(gt=0)
stop_sequences: list[str] = Field(default_factory=list)
@field_validator("model_name")
@classmethod
def validate_model_name(cls, v: str) -> str:
"""Model names must match supported prefixes."""
allowed_prefixes = ("gpt-", "o", "claude-", "o3-")
if not any(v.startswith(prefix) for prefix in allowed_prefixes):
raise ValueError(
f"Unsupported model: {v}. Must start with one of: {allowed_prefixes}"
)
return v
@field_validator("stop_sequences")
@classmethod
def validate_stop_sequences(cls, v: list[str]) -> list[str]:
"""Stop sequences must not contain empty strings."""
if any(not seq.strip() for seq in v):
raise ValueError("Stop sequences must not be empty or whitespace-only")
return v
# β
Valid
params = LLMParameters(
model_name="gpt-4o",
temperature=0.7,
max_tokens=4096,
stop_sequences=["\nUser:", "\nAssistant:"],
)
# β ValidationError β unsupported model name
# params = LLMParameters(model_name="custom-model", temperature=0.7, max_tokens=100)
# β ValueError: Unsupported model: custom-model
# β ValidationError β empty stop sequence
# params = LLMParameters(
# model_name="gpt-4o",
# temperature=0.7,
# max_tokens=100,
# stop_sequences=["\nUser:", ""],
# )
# β ValueError: Stop sequences must not be empty or whitespace-only
Model Validatorsβ
@model_validator validates across multiple fields. Use mode="after" to run after field validation, or mode="before" to run before:
from pydantic import BaseModel, Field, model_validator
class TokenBudget(BaseModel):
max_input_tokens: int = Field(gt=0)
max_output_tokens: int = Field(gt=0)
context_window: int = Field(gt=0)
@model_validator(mode="after")
def validate_token_budget(self) -> "TokenBudget":
"""Input + output tokens must fit within the context window."""
total = self.max_input_tokens + self.max_output_tokens
if total > self.context_window:
raise ValueError(
f"Token budget exceeded: {total} > context window {self.context_window}. "
f"Reduce max_input_tokens or max_output_tokens."
)
return self
# β
Valid β 8192 + 4096 = 12288 <= 128000
budget = TokenBudget(
max_input_tokens=8192,
max_output_tokens=4096,
context_window=128_000,
)
# β ValidationError β budget exceeds context window
# budget = TokenBudget(
# max_input_tokens=120_000,
# max_output_tokens=20_000,
# context_window=128_000,
# )
# β ValueError: Token budget exceeded: 140000 > context window 128000
BeforeValidator and AfterValidatorβ
For inline validation without decorator syntax, use BeforeValidator and AfterValidator with Annotated:
from pydantic import BaseModel, BeforeValidator, AfterValidator
from typing import Annotated
def strip_and_lower(v: str) -> str:
return v.strip().lower()
def check_not_empty(v: str) -> str:
if not v:
raise ValueError("Query must not be empty after trimming")
return v
CleanQuery = Annotated[
str,
BeforeValidator(strip_and_lower),
AfterValidator(check_not_empty),
]
class SearchRequest(BaseModel):
query: CleanQuery
max_results: int = 5
# β
Valid β whitespace trimmed, lowercased
req = SearchRequest(query=" Pydantic ")
print(req.query) # "pydantic"
# β ValidationError β empty after trim
# req = SearchRequest(query=" ")
# β ValueError: Query must not be empty after trimming
Custom Exception Handlingβ
Pydantic ValidationError gives structured access to errors:
from pydantic import BaseModel, ValidationError
class ToolOutput(BaseModel):
tool_name: str
result: str
is_error: bool = False
def parse_tool_output(raw: dict) -> ToolOutput:
"""Parse and validate tool output from LLM."""
try:
return ToolOutput.model_validate(raw)
except ValidationError as exc:
# Log structured error details
for error in exc.errors():
print(f"Field: {error['loc']}, Issue: {error['msg']}")
raise RuntimeError(f"Tool output validation failed: {exc}") from exc
# This raises RuntimeError with details
# parse_tool_output({"tool_name": "search"}) # missing 'result' field
Serializationβ
Converting to Dictionary and JSONβ
from pydantic import BaseModel, Field
class AgentState(BaseModel):
query: str
steps_completed: int = 0
findings: list[str] = Field(default_factory=list)
is_done: bool = False
state = AgentState(
query="Research Python async patterns",
steps_completed=3,
findings=["Found asyncio docs", "Found LangChain examples"],
)
# Convert to dictionary
data: dict = state.model_dump()
# {'query': 'Research Python async patterns', 'steps_completed': 3, ...}
# Convert to JSON string
json_str: str = state.model_dump_json()
# '{"query":"Research Python async patterns","steps_completed":3,...}'
Deserializing from Dictionary and JSONβ
# From dictionary
state = AgentState.model_validate({
"query": "Research LangGraph",
"steps_completed": 1,
"findings": ["Found LangGraph docs"],
})
# From JSON string
state = AgentState.model_validate_json(
'{"query": "Research LangGraph", "steps_completed": 1, "findings": ["Found LangGraph docs"]}'
)
Serialization Modesβ
mode='python' (default) returns Python-native types. mode='json' returns JSON-serializable types:
from datetime import datetime
from pydantic import BaseModel
class TimestampedEvent(BaseModel):
event_type: str
timestamp: datetime
event = TimestampedEvent(
event_type="tool_call",
timestamp=datetime(2025, 7, 8, 14, 30, 0),
)
# mode='python' β datetime object
python_data = event.model_dump(mode="python")
print(type(python_data["timestamp"])) # <class 'datetime.datetime'>
# mode='json' β ISO 8601 string
json_data = event.model_dump(mode="json")
print(type(json_data["timestamp"])) # <class 'str'>
print(json_data["timestamp"]) # '2025-07-08T14:30:00'
Partial Serializationβ
Use exclude and include to control what gets serialized:
from pydantic import BaseModel, Field, SecretStr
class APIConfig(BaseModel):
endpoint: str
api_key: SecretStr
timeout: float = 30.0
headers: dict[str, str] = Field(default_factory=dict)
config = APIConfig(
endpoint="https://api.example.com",
api_key="sk-secret-key-12345",
timeout=15.0,
headers={"X-Request-ID": "req-001"},
)
# Exclude sensitive fields
safe_dump = config.model_dump(exclude={"api_key"})
# {'endpoint': '...', 'timeout': 15.0, 'headers': {...}}
# Include only specific fields
minimal_dump = config.model_dump(include={"endpoint", "timeout"})
# {'endpoint': '...', 'timeout': 15.0}
# SecretStr is always redacted in model_dump
print(config.model_dump())
# {'endpoint': '...', 'api_key': '**********', ...}
Aliases for API Response Shapingβ
Use validation_alias and serialization_alias to map between internal field names and external API names:
from pydantic import BaseModel, Field
class ToolResponse(BaseModel):
tool_name: str = Field(
validation_alias="toolName",
serialization_alias="toolName",
)
output: str = Field(
validation_alias="toolOutput",
serialization_alias="toolOutput",
)
error_message: str | None = Field(
default=None,
validation_alias="errorMessage",
serialization_alias="errorMessage",
)
model_config = {"populate_by_name": True}
# Deserialize from camelCase API response
response = ToolResponse.model_validate({
"toolName": "web_search",
"toolOutput": "Found results",
})
# Serialize back to camelCase
api_output = response.model_dump(by_alias=True)
# {'toolName': 'web_search', 'toolOutput': 'Found results', 'errorMessage': None}
Configurationβ
model_configβ
Customize model behavior with the model_config class attribute:
from pydantic import BaseModel, ConfigDict, Field
class StrictInput(BaseModel):
"""API input model β rejects unknown fields."""
query: str
max_results: int = 5
model_config = ConfigDict(
extra="forbid", # Reject unknown fields
str_strip_whitespace=True, # Auto-trim strings
)
# β
Valid
input_data = StrictInput(query=" Pydantic ", max_results=10)
print(input_data.query) # "Pydantic" (whitespace stripped)
# β ValidationError β unknown field rejected
# input_data = StrictInput(query="test", max_results=5, unknown_field="value")
# β ValidationError: extra fields not permitted
Common Configuration Optionsβ
from pydantic import BaseModel, ConfigDict, Field
class ImmutableConfig(BaseModel):
"""Immutable model β fields cannot be changed after creation."""
model_name: str
temperature: float = 0.7
model_config = ConfigDict(
frozen=True, # Immutable β no field changes allowed
)
config = ImmutableConfig(model_name="gpt-4o")
# config.temperature = 0.3 # β ValidationError: Instance is frozen
class FlexibleAliasModel(BaseModel):
"""Accept both field name and alias during validation."""
tool_name: str = Field(validation_alias="toolName")
model_config = ConfigDict(
populate_by_name=True, # Accept both 'tool_name' and 'toolName'
)
# Both work:
m1 = FlexibleAliasModel.model_validate({"tool_name": "search"})
m2 = FlexibleAliasModel.model_validate({"toolName": "search"})
| Config Option | Effect | When to Use |
|---|---|---|
extra="forbid" | Reject unknown fields | API request bodies β catch typos early |
extra="ignore" | Silently drop unknown fields | Parsing external data with optional extras |
extra="allow" | Store unknown fields in model_extra | Flexible schemas where extras are expected |
str_strip_whitespace=True | Trim leading/trailing whitespace from strings | User input fields |
str_min_length, str_max_length | Global string length constraints | When all string fields share the same limits |
frozen=True | Make the model immutable | Configuration objects, cached state |
populate_by_name=True | Accept both field name and alias | APIs that use camelCase but code uses snake_case |
extra="forbid"For all API input models, we use extra="forbid" as the default. It catches typos, schema drift, and unexpected fields at validation time rather than silently accepting them and causing downstream bugs.
Pydantic and Type Hintsβ
Annotated Typesβ
Annotated lets you attach metadata (like Field constraints) directly to the type:
from pydantic import BaseModel, Field
from typing import Annotated
PositiveInt = Annotated[int, Field(gt=0)]
BoundedFloat = Annotated[float, Field(ge=0.0, le=1.0)]
NonEmptyStr = Annotated[str, Field(min_length=1)]
class AgentConfig(BaseModel):
max_iterations: PositiveInt
temperature: BoundedFloat = 0.7
system_prompt: NonEmptyStr
tools: list[str] = Field(default_factory=list)
config = AgentConfig(
max_iterations=10,
system_prompt="You are a helpful research assistant.",
)
TypeAdapterβ
TypeAdapter validates data that is not a model β useful for standalone types:
from pydantic import TypeAdapter, Field, ValidationError
from typing import Annotated
# Validate a list of model names
ModelNameList = TypeAdapter(list[Annotated[str, Field(min_length=1)]])
valid_models = ModelNameList.validate_python(["gpt-4o", "o3-mini"])
print(valid_models) # ['gpt-4o', 'o3-mini']
# Validate a dictionary mapping
from typing import Dict
ScoreMap = TypeAdapter(Dict[str, float])
scores = ScoreMap.validate_python({"relevance": 0.92, "freshness": 0.78})
# Validate and dump
json_output = ModelNameList.dump_json(valid_models)
print(json_output) # b'["gpt-4o","o3-mini"]'
Generic Modelsβ
Use TypeVar to create reusable model templates:
from pydantic import BaseModel, Field
from typing import Generic, TypeVar
T = TypeVar("T")
class ToolResult(BaseModel, Generic[T]):
"""Generic wrapper for tool execution results."""
tool_name: str
success: bool
data: T | None = None
error: str | None = None
# Use with specific types
search_result = ToolResult[list[dict[str, str]]](
tool_name="web_search",
success=True,
data=[{"title": "Pydantic docs", "url": "https://docs.pydantic.dev"}],
)
string_result = ToolResult[str](
tool_name="calculator",
success=False,
error="Division by zero",
)
Computed Fieldsβ
@computed_field derives values from existing fields without storing them:
from pydantic import BaseModel, computed_field, Field
class SearchRequest(BaseModel):
query: str
max_results: int = Field(default=5, ge=1, le=50)
page: int = Field(default=1, ge=1)
@computed_field
@property
def skip_count(self) -> int:
"""Calculate skip offset for pagination."""
return (self.page - 1) * self.max_results
@computed_field
@property
def normalized_query(self) -> str:
"""Return trimmed, lowercased query."""
return self.query.strip().lower()
req = SearchRequest(query=" Python ", max_results=10, page=3)
print(req.skip_count) # 20
print(req.normalized_query) # "python"
# Computed fields appear in model_dump()
print(req.model_dump())
# {'query': ' Python ', 'max_results': 10, 'page': 3,
# 'skip_count': 20, 'normalized_query': 'python'}
Pydantic in AI Workflowsβ
Validating LLM Tool Call Outputsβ
When an LLM calls a tool, the output is a string. Pydantic validates it into a trusted structure:
from pydantic import BaseModel, Field, ValidationError
class SearchToolOutput(BaseModel):
results: list[dict[str, str]] = Field(min_length=0, max_length=20)
total_count: int = Field(ge=0)
query_used: str
def validate_tool_output(raw_output: str) -> SearchToolOutput:
"""Parse and validate LLM tool call output."""
import json
try:
parsed = json.loads(raw_output)
return SearchToolOutput.model_validate(parsed)
except (json.JSONDecodeError, ValidationError) as exc:
raise RuntimeError(f"Tool output validation failed: {exc}") from exc
# Simulated LLM tool output
llm_output = json.dumps({
"results": [
{"title": "Pydantic v2", "url": "https://docs.pydantic.dev"},
],
"total_count": 1,
"query_used": "Pydantic validation",
})
validated = validate_tool_output(llm_output)
print(validated.results[0]["title"]) # "Pydantic v2"
Structuring Agent Stateβ
Agent state in LangGraph is defined as a Pydantic model:
from pydantic import BaseModel, Field
from typing import Literal
class AgentState(BaseModel):
"""State schema for a research agent."""
query: str
plan: list[str] = Field(default_factory=list)
findings: list[str] = Field(default_factory=list)
sources: list[str] = Field(default_factory=list)
current_step: int = 0
status: Literal["planning", "executing", "done", "error"] = "planning"
error_message: str | None = None
model_config = {"extra": "forbid"}
# Initial state
state = AgentState(query="Compare Pydantic v1 vs v2")
# Update state during execution
state.current_step = 1
state.status = "executing"
state.plan = ["Search for Pydantic v2 changelog", "Compare breaking changes"]
# Serialize for checkpointing
checkpoint = state.model_dump_json()
Validating Azure AI Search Resultsβ
When querying Azure AI Search, results come back as dictionaries. Pydantic validates them:
from pydantic import BaseModel, Field
from typing import Annotated
PositiveFloat = Annotated[float, Field(gt=0.0)]
class SearchDocument(BaseModel):
"""Schema for a document returned by Azure AI Search."""
id: str
title: str
content: str
url: str
embedding: list[float] = Field(default_factory=list)
metadata: dict[str, str] = Field(default_factory=dict)
class AzureSearchResponse(BaseModel):
"""Wrapper for the full Azure AI Search response."""
query: str
documents: list[SearchDocument]
total_count: int = Field(ge=0)
search_latency_ms: PositiveFloat
# Simulated Azure AI Search response
raw_response = {
"query": "Python async patterns",
"documents": [
{
"id": "doc-001",
"title": "AsyncIO Guide",
"content": "Python's asyncio module provides...",
"url": "https://docs.python.org/3/library/asyncio.html",
"embedding": [0.1, 0.2, 0.3],
"metadata": {"source": "python.org", "language": "en"},
},
],
"total_count": 1,
"search_latency_ms": 23.5,
}
validated = AzureSearchResponse.model_validate(raw_response)
print(validated.documents[0].title) # "AsyncIO Guide"
Testing Pydantic Modelsβ
Parametrized Validation Testsβ
import pytest
from pydantic import BaseModel, Field, ValidationError
class LLMConfig(BaseModel):
model_name: str = Field(min_length=1)
temperature: float = Field(ge=0.0, le=2.0)
max_tokens: int = Field(gt=0, le=128_000)
@pytest.mark.parametrize(
"model_name,temperature,max_tokens,should_fail",
[
("gpt-4o", 0.7, 4096, False),
("o3-mini", 0.0, 1, False),
("claude-sonnet-4", 2.0, 128_000, False),
("", 0.7, 4096, True), # Empty model name
("gpt-4o", -0.1, 4096, True), # Negative temperature
("gpt-4o", 2.1, 4096, True), # Temperature too high
("gpt-4o", 0.7, 0, True), # Zero max_tokens
("gpt-4o", 0.7, 128_001, True), # max_tokens too high
],
)
def test_llm_config_validation(
model_name: str,
temperature: float,
max_tokens: int,
should_fail: bool,
) -> None:
"""LLMConfig validates model parameters correctly."""
kwargs = {
"model_name": model_name,
"temperature": temperature,
"max_tokens": max_tokens,
}
if should_fail:
with pytest.raises(ValidationError):
LLMConfig(**kwargs)
else:
config = LLMConfig(**kwargs)
assert config.model_name == model_name
assert config.temperature == temperature
assert config.max_tokens == max_tokens
Testing Invalid Inputsβ
import pytest
from pydantic import BaseModel, ValidationError
class SearchRequest(BaseModel):
query: str
max_results: int
def test_search_request_rejects_invalid_types() -> None:
"""SearchRequest rejects non-string query and non-int max_results."""
with pytest.raises(ValidationError) as exc_info:
SearchRequest(query=123, max_results="five")
errors = exc_info.value.errors()
assert len(errors) == 2
assert errors[0]["loc"] == ("query",)
assert errors[1]["loc"] == ("max_results",)
Snapshot Testing for Serializationβ
import pytest
from pydantic import BaseModel, Field
class ToolOutput(BaseModel):
tool_name: str
result: str
is_error: bool = False
metadata: dict[str, str] = Field(default_factory=dict)
def test_tool_output_serialization() -> None:
"""ToolOutput serializes to expected JSON structure."""
output = ToolOutput(
tool_name="web_search",
result="Found 5 results",
is_error=False,
metadata={"query": "test", "source": "internal"},
)
dumped = output.model_dump()
assert dumped == {
"tool_name": "web_search",
"result": "Found 5 results",
"is_error": False,
"metadata": {"query": "test", "source": "internal"},
}
json_str = output.model_dump_json()
assert "web_search" in json_str
assert "Found 5 results" in json_str
Key Takeawaysβ
- Pydantic validates at the boundary β use it for API inputs, tool outputs, agent state, and search results. Every untrusted data path goes through a model.
BaseModelis your workhorse β define fields with type annotations, add constraints withField(), and let Pydantic handle validation.@field_validatorfor single fields,@model_validatorfor cross-field rules β custom validation lives here, not in scatteredifstatements.- Serialization is built in β
.model_dump(),.model_dump_json(),.model_validate()handle Python-to-JSON round-trips. model_config = ConfigDict(extra="forbid")for API inputs β reject unknown fields to catch errors early.- Use
Annotated+Fieldfor reusable type constraints βPositiveInt,BoundedFloat, etc. compose cleanly. TypeAdaptervalidates non-model data β lists, dicts, and primitive collections get the same validation rigor.- Test validation, not just happy paths β parametrized tests for invalid inputs catch schema drift before it reaches production.
Further Readingβ
- Pydantic v2 Documentation β Official reference, migration guide, and tutorials
- Pydantic v2 Migration Guide β Step-by-step guide from v1 to v2
- Pydantic Settings β Loading configuration from environment variables and
.envfiles - Pydantic Custom Types β Extending Pydantic with custom type validators
- Best Practices β Our team's Python conventions and tooling setup