LLM outputs are notoriously unstructured. You ask for JSON and get markdown-wrapped prose. You define a schema and the model skips fields or invents new ones. PydanticAI solves this by making type-safe structured output the core of its agent model — not a post-processing step bolted on after the fact PydanticAI Official Documentation.
Built by the team behind Pydantic (the most widely used data validation library in Python), PydanticAI provides an agent framework where every agent has a typed result, type-checked tool signatures, and dependency injection that composes cleanly with your existing codebase GitHub: pydantic/pydantic-ai. This guide walks through building a multi-step document analysis agent from scratch.
Prerequisites
- Python 3.10+ (3.11+ recommended for
Selftype and betterdataclasssupport) pip install pydantic-ai— the full framework (includes model providers)- An API key for at least one supported provider: OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible endpoint
Step 1: Your First Structured Agent
PydanticAI agents declare their output type at construction time via the result_type parameter. The framework handles LLM response parsing, validation, and retry.
from pydantic import BaseModel
from pydantic_ai import Agent
class DocumentSummary(BaseModel):
title: str
word_count: int
key_topics: list[str]
sentiment: str # positive, negative, neutral
agent = Agent(
"openai:gpt-4o",
result_type=DocumentSummary,
system_prompt="Extract structured metadata from the given document.",
)
result = agent.run_sync(
"The new FPGA accelerator achieves 4.2 TFLOPS at 45W thermal design power, "
"marking a 3x improvement over the previous generation. Early benchmarks "
"show particular strength in sparse matrix operations."
)
print(result.data)
# DocumentSummary(
# title='FPGA Accelerator Benchmarks',
# word_count=28,
# key_topics=['FPGA', 'accelerator', 'benchmarks', 'sparse matrix'],
# sentiment='positive'
# )
The result.data field is a fully validated DocumentSummary instance. If the LLM returns malformed output, PydanticAI automatically retries with the validation error message as context — no manual parsing or error handling needed PydanticAI: Output Validation.
Step 2: Registering Tools with Type-Safe Signatures
Tools in PydanticAI are plain Python functions with type annotations. The framework infers the JSON schema for tool calling from the function signature and docstring.
Use @agent.tool when the tool needs access to the agent’s context (dependencies). Use @agent.tool_plain for stateless tools.
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
@dataclass
class SearchDep:
api_key: str
max_results: int = 5
search_agent = Agent(
"anthropic:claude-sonnet-4-20260514",
deps_type=SearchDep,
result_type=list[str],
)
@search_agent.tool
async def web_search(ctx: RunContext[SearchDep], query: str) -> list[str]:
"""Search the web for recent information on a topic.
Args:
ctx: Agent context with API credentials.
query: The search query string.
Returns:
A list of result URLs.
"""
# ctx.deps.api_key available here
results = await run_search_api(query, api_key=ctx.deps.api_key)
return [r.url for r in results[: ctx.deps.max_results]]
Key details:
- The LLM sees the function’s docstring as the tool description and type annotations as parameter schemas.
RunContext[T]gives access to dependencies injected at runtime.- Tools can be sync or async; the agent handles both.
- Return type annotations are validated at runtime — if a tool returns something unexpected, the agent catches it before sending to the LLM PydanticAI: Tools.
Step 3: Dependency Injection for Composable Agents
PydanticAI’s dependency system decouples agent logic from external services. Dependencies are declared via deps_type and passed at runtime through the run() / run_sync() call.
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
import httpx
@dataclass
class DocServiceDep:
client: httpx.AsyncClient
base_url: str
auth_token: str
class AnalysisResult(BaseModel):
document_id: str
entities: list[str]
summary: str
risk_score: float
doc_agent = Agent(
"google-gla:gemini-2.5-pro",
deps_type=DocServiceDep,
result_type=AnalysisResult,
system_prompt="Analyze documents and produce structured risk assessments.",
)
@doc_agent.tool
async def fetch_document(ctx: RunContext[DocServiceDep], doc_id: str) -> str:
"""Retrieve a document from the content service by ID."""
headers = {"Authorization": f"Bearer {ctx.deps.auth_token}"}
response = await ctx.deps.client.get(
f"{ctx.deps.base_url}/docs/{doc_id}", headers=headers
)
response.raise_for_status()
return response.text
# Runtime injection
async def analyze_document(doc_id: str) -> AnalysisResult:
async with httpx.AsyncClient() as client:
deps = DocServiceDep(
client=client,
base_url="https://api.internal.example.com",
auth_token=os.environ["DOC_SERVICE_TOKEN"],
)
result = await doc_agent.run(
f"Analyze document {doc_id} for compliance risks",
deps=deps,
)
return result.data
This pattern lets you swap implementations without changing agent code — use a mock client in tests, a real client in production, and inject different credentials per environment PydanticAI: Dependencies.
Step 4: Multi-Step Workflows with Agent Composition
Real agent workflows involve multiple steps: fetch → classify → enrich → report. Each step is its own agent; you orchestrate them in plain Python.
from pydantic import BaseModel
from pydantic_ai import Agent
# Step 1: Classify the document
class DocumentClass(BaseModel):
category: str
confidence: float
language: str
classifier = Agent(
"openai:gpt-4o-mini",
result_type=DocumentClass,
system_prompt="Classify the document into a category.",
)
# Step 2: Extract structured data
class ExtractedData(BaseModel):
dates: list[str]
monetary_amounts: list[float]
named_entities: dict[str, list[str]]
extractor = Agent(
"openai:gpt-4o-mini",
result_type=ExtractedData,
system_prompt="Extract all dates, monetary values, and named entities.",
)
# Step 3: Generate the final report
class FinalReport(BaseModel):
classification: DocumentClass
extracted: ExtractedData
recommendations: list[str]
reporter = Agent(
"openai:gpt-4o",
result_type=FinalReport,
system_prompt=(
"Combine classification and extracted data into a structured "
"compliance report with actionable recommendations."
),
)
async def process_document(text: str) -> FinalReport:
# Step 1
cls = await classifier.run(text)
# Step 2
data = await extractor.run(text)
# Step 3 — pass previous results as context
report = await reporter.run(
f"Classification: {cls.data.model_dump_json()}\n"
f"Extracted data: {data.data.model_dump_json()}"
)
return report.data
Each agent is independently testable. You can swap models per step (use cheap models for classification, expensive ones for report generation). The pipeline is just function composition — no framework-specific DAG syntax to learn Real Python: Building Type-Safe LLM Agents With PydanticAI.
Step 5: Retry, Fallback, and Error Handling
PydanticAI’s ModelRetry exception lets tools signal the LLM to retry with additional context. Combine this with agent run settings for robust error handling.
from pydantic_ai import Agent, ModelRetry
from pydantic_ai.exceptions import ModelRetryError
import httpx
agent = Agent("openai:gpt-4o-mini")
@agent.tool
async def fetch_weather(city: str) -> dict:
"""Get current weather for a city. Retry if the API is temporarily down."""
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.weather.example.com/v1/{city}",
timeout=5.0,
)
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException:
raise ModelRetry("Weather API timed out, please try again")
except httpx.HTTPStatusError as e:
if e.response.status_code == 503:
raise ModelRetry("Weather service temporarily unavailable, retry later")
raise
# Run with retry configuration
result = agent.run_sync(
"What's the weather in Tokyo?",
max_retries=3, # Max retries for the whole run
max_result_retries=2, # Max retries for result validation
)
When ModelRetry is raised, the agent sends the error message back to the LLM and lets it decide how to proceed — try the tool again, try a different city, or explain the failure to the user. This is fundamentally different from a hard exception that crashes the agent PydanticAI: ModelRetry.
Step 6: Streaming Structured Outputs
For long-running agents, PydanticAI supports streaming both text tokens and structured result updates.
from pydantic_ai import Agent
from pydantic import BaseModel
class StreamResult(BaseModel):
progress: float
current_step: str
partial_output: str | None = None
agent = Agent(
"openai:gpt-4o",
result_type=StreamResult,
)
async def stream_analysis():
async with agent.run_stream(
"Process this 500-page annual report..."
) as stream:
async for partial in stream.stream_structured():
# partial.data is a partial StreamResult
if partial.data:
print(
f"Progress: {partial.data.progress:.0%} — "
f"{partial.data.current_step}"
)
# Final validated result
final = await stream.get_data()
return final
Streaming structured outputs enables real-time progress indicators in UIs without waiting for the full agent run to complete PydanticAI: Streaming.
Best Practices
-
Use specific models per task — Classification and extraction work well with
gpt-4o-miniorclaude-3-haiku. Synthesis and report generation benefit fromgpt-4oorclaude-sonnet-4. Setmodelper-agent, not globally. -
Keep tools focused — Each tool should do one thing well. A tool that “searches and summarizes and formats” is harder for the LLM to reason about than three separate tools Anthropic: Tool Use Best Practices.
-
Prefer
deps_typeover closures — Dependency injection throughRunContextis testable and composable. Closing over globals in tool functions breaks isolation and makes testing impossible. -
Set
max_result_retries— The default is low. For complex result types, setmax_result_retries=3to give the LLM room to correct malformed outputs before surfacing an error to the user. -
Use
model_dump_json()for agent-to-agent communication — When passing results between agents, serialize withmodel_dump_json()and deserialize withmodel_validate_json()at the receiving end. This preserves type safety across the pipeline. -
Pin your pydantic-ai version — The framework is under active development. Pin to a specific version in
requirements.txtand test upgrades against your agent suite before deploying.
Key Takeaways
- PydanticAI makes structured output the core contract, not an afterthought — every agent has a typed
result_typethat’s validated at runtime. - Tools are plain Python functions with type annotations; the framework generates JSON schemas automatically from function signatures.
- Dependency injection via
deps_type/RunContextdecouples agent logic from infrastructure — swap implementations without changing agent code. - Multi-step workflows are plain Python function composition, not a framework-specific DAG — each agent is independently testable.
ModelRetrygives the LLM a chance to recover from tool failures instead of crashing the agent.
References
- PydanticAI Official Documentation — Framework overview and API reference PydanticAI Official Documentation
- GitHub: pydantic/pydantic-ai — Source code, issues, and examples GitHub: pydantic/pydantic-ai
- PydanticAI: Output Validation — How structured output validation and retry work PydanticAI: Output Validation
- PydanticAI: Tools — Tool registration and context access PydanticAI: Tools
- PydanticAI: Dependencies — Dependency injection patterns PydanticAI: Dependencies
- Real Python: Building Type-Safe LLM Agents With PydanticAI — Walkthrough of multi-agent composition Real Python: Building Type-Safe LLM Agents With PydanticAI
- PydanticAI: ModelRetry — Error recovery and retry mechanism PydanticAI: ModelRetry
- PydanticAI: Streaming — Streaming structured and text output PydanticAI: Streaming
- Anthropic: Tool Use Best Practices — General guidance on tool design for LLMs Anthropic: Tool Use Best Practices
📖 Related Reads
- NiteAgent: Multi-Agent Pipeline Resiliency Patterns — Error recovery and circuit-breaking for agent pipelines
- NiteAgent: Production Tool-Calling Architecture — Patterns for reliable tool dispatch in production
- NiteAgent: AI Agent Testing Strategies 2026 — Evaluating and testing agent frameworks in production
Cross-links automatically generated from NiteAgent.
← Back to all posts


