Opening
You’re building an AI-powered feature — maybe a support bot that extracts order details, or a pipeline that classifies incoming emails. You wire up the OpenAI SDK, parse the response JSON manually, and cross your fingers that the model returns what you expect. Half the time it doesn’t. You add try/except blocks, regex fallbacks, and a growing pile of validation logic that’s harder to maintain than the feature itself.
PydanticAI fixes this at the root. Built by the same team behind Pydantic — the validation library already embedded in the OpenAI, Anthropic, and Google SDKs — PydanticAI is an agent framework that enforces structured, type-safe outputs from any LLM. If you’ve used FastAPI, the design will feel instantly familiar: define a Pydantic model for what you want back, and the framework guarantees it.
After reading this, you’ll be able to:
Core Content
Your First PydanticAI Agent with Structured Output
Install PydanticAI from PyPI (requires Python 3.10+):
pip install pydantic-ai
The core concept: you define a Pydantic model for the output you want, create an Agent with that model as its output_type, and run it. PydanticAI handles the LLM interaction and validates the response against your schema — if the model returns something that doesn’t match, it automatically retries with corrective prompting.
# pip install pydantic-ai
import asyncio
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class MovieReview(BaseModel):
"""Structured review of a movie."""
title: str = Field(description="The movie title")
year: int = Field(description="Release year")
rating: float = Field(ge=0.0, le=10.0, description="Rating out of 10")
pros: list[str] = Field(description="What worked well", min_length=1)
cons: list[str] = Field(description="What didn't work")
one_line_verdict: str = Field(max_length=100)
agent = Agent(
"openai:gpt-4o",
output_type=MovieReview,
system_prompt="You are a film critic. Analyze movies objectively.",
)
async def main() -> None:
result = await agent.run("Review the movie Dune: Part Two")
review: MovieReview = result.output # Fully typed — your IDE knows this is MovieReview
print(f"{review.title} ({review.year}): {review.rating}/10")
print(f"Pros: {', '.join(review.pros)}")
print(f"Cons: {', '.join(review.cons)}")
print(f"Verdict: {review.one_line_verdict}")
asyncio.run(main())
# Output:
# Dune: Part Two (2024): 8.7/10
# Pros: Stunning cinematography, Hans Zimmer's score, Timothée Chalamet's performance
# Cons: Dense plot may lose casual viewers, some pacing issues in the second act
# Verdict: A visually breathtaking sci-fi epic that rewards patient audiences.
Notice how result.output is typed as MovieReview — your IDE gives you autocomplete on .title, .rating, .pros, etc. The Field constraints (ge=0.0, le=10.0, max_length=100) are enforced automatically. If the LLM returns a rating of 15, PydanticAI catches it and asks the model to correct itself.
Adding Tools Your Agent Can Call
Agents become powerful when they can take actions — calling APIs, querying databases, fetching live data. PydanticAI’s tool system is decorator-based and fully type-checked.
# pip install pydantic-ai httpx
import asyncio
import httpx
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
class WeatherReport(BaseModel):
"""Structured weather information."""
city: str
temperature_c: float
condition: str
recommendation: str = Field(description="What to wear or bring")
agent = Agent(
"openai:gpt-4o",
output_type=WeatherReport,
system_prompt="You help people plan their day based on weather data.",
)
@agent.tool_plain
async def get_current_weather(city: str) -> str:
"""Fetch current weather for a city. Returns a text summary."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://wttr.in/{city}?format=%C+%t+%h",
headers={"User-Agent": "curl"},
)
return f"Weather in {city}: {response.text.strip()}"
async def main() -> None:
result = await agent.run("What's the weather like in Tokyo right now?")
report: WeatherReport = result.output
print(f"{report.city}: {report.temperature_c}°C, {report.condition}")
print(f"Recommendation: {report.recommendation}")
asyncio.run(main())
# Output:
# Tokyo: 18.0°C, Partly cloudy
# Recommendation: Light jacket recommended, no umbrella needed.
When the agent runs, it sees get_current_weather as an available tool with its type signature and docstring. If the user asks about weather, the model calls the tool, gets real data, and structures the final output. The @agent.tool_plain decorator is for tools that don’t need runtime dependencies — use @agent.tool (with RunContext) when they do.
Dependency Injection for Production Code
Real applications need database connections, authenticated API clients, and user session data. Hardcoding these inside tools is a maintenance nightmare. PydanticAI solves this with dependency injection — you declare a deps_type on your agent, and every tool receives those dependencies via RunContext.
# pip install pydantic-ai
import asyncio
from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
# Dependencies your tools need at runtime
@dataclass
class SupportDeps:
customer_name: str
order_db: dict[str, dict] # In production, this would be a DB client
class SupportResponse(BaseModel):
"""Structured customer support response."""
greeting: str
order_status: str
estimated_delivery: str | None
follow_up_action: str
agent = Agent(
"openai:gpt-4o",
deps_type=SupportDeps,
output_type=SupportResponse,
system_prompt="You are a helpful customer support agent. Use the tools to look up order info.",
)
@agent.tool
async def lookup_order(ctx: RunContext[SupportDeps], order_id: str) -> str:
"""Look up an order by ID and return its status."""
# ctx.deps gives you the injected dependencies — fully typed
order = ctx.deps.order_db.get(order_id)
if not order:
return f"No order found with ID {order_id}"
return f"Order {order_id}: status={order['status']}, delivery={order['delivery']}"
async def main() -> None:
# Inject dependencies at runtime — not at agent definition time
deps = SupportDeps(
customer_name="Sarah",
order_db={
"ORD-123": {"status": "shipped", "delivery": "April 10"},
"ORD-456": {"status": "processing", "delivery": "April 15"},
},
)
result = await agent.run(
"Hi, can you check on my order ORD-123?",
deps=deps,
)
response: SupportResponse = result.output
print(f"Greeting: {response.greeting}")
print(f"Status: {response.order_status}")
print(f"Delivery: {response.estimated_delivery}")
print(f"Next step: {response.follow_up_action}")
asyncio.run(main())
# Output:
# Greeting: Hi Sarah! Let me look that up for you.
# Status: Your order ORD-123 has been shipped.
# Delivery: April 10
# Next step: You'll receive a tracking email within 24 hours.
The key insight: RunContext[SupportDeps] is generic — your IDE and type checker know exactly what ctx.deps contains. If you try to access ctx.deps.nonexistent_field, mypy catches it before runtime. This is what “FastAPI feeling” means for agent development.
Switching Models Without Changing Code
PydanticAI is model-agnostic — it supports OpenAI, Anthropic, Google Gemini, Groq, Ollama, and 25+ other providers. Switching models is a one-line change because the agent logic, tools, and output types are all decoupled from the model.
# pip install pydantic-ai
import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent
class Summary(BaseModel):
"""A concise text summary."""
main_point: str
key_details: list[str]
word_count: int
text = """
Python 3.14 introduced concurrent.interpreters, enabling true parallelism
through subinterpreters with isolated GILs. This allows CPU-bound work to
run in parallel threads without the traditional GIL bottleneck, achieving
near-linear scaling on multi-core machines.
"""
async def summarize_with_model(model: str) -> Summary:
"""Run the same agent logic with different models."""
agent = Agent(
model,
output_type=Summary,
system_prompt="Summarize technical text concisely.",
)
result = await agent.run(f"Summarize this: {text}")
return result.output
async def main() -> None:
models = [
"openai:gpt-4o",
"anthropic:claude-sonnet-4-20250514",
"google-gla:gemini-2.0-flash",
# "ollama:llama3.2" # uncomment if running Ollama locally
]
for model in models:
try:
summary = await summarize_with_model(model)
print(f"\n--- {model} ---")
print(f"Main point: {summary.main_point}")
print(f"Details: {summary.key_details}")
except Exception as e:
print(f"\n--- {model} --- Error: {e}")
asyncio.run(main())
# Output:
# --- openai:gpt-4o ---
# Main point: Python 3.14 enables true multi-core parallelism via subinterpreters
# Details: ['concurrent.interpreters module added', 'Each has isolated GIL', 'Near-linear scaling']
#
# --- anthropic:claude-sonnet-4-20250514 ---
# Main point: Subinterpreters bypass the GIL for genuine parallel execution
# Details: ['New in Python 3.14', 'CPU-bound work scales across cores', 'Thread-based approach']
Same Summary model, same system prompt, same validation — different LLM underneath. In production, you’d typically set the model via an environment variable or config file rather than hardcoding it.
Common Mistakes
Mistake 1: Using agent.run_sync() Inside an Async Context
Wrong:
import asyncio
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o", output_type=str)
async def handler():
# run_sync() blocks the event loop — defeats the purpose of async
result = agent.run_sync("Hello") # RuntimeError or deadlock!
return result.output
Right:
import asyncio
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o", output_type=str)
async def handler():
# Use the async version inside async functions
result = await agent.run("Hello")
return result.output
Why this happens: run_sync() is a convenience wrapper that creates its own event loop. Calling it inside an existing async context (like a FastAPI endpoint or an asyncio.run() block) tries to nest event loops, which Python’s asyncio doesn’t allow. The PydanticAI docs on agents note that run_sync() is only for scripts and notebooks — use await agent.run() everywhere else.
Mistake 2: Forgetting to Set the API Key Environment Variable
Wrong:
from pydantic_ai import Agent
# No OPENAI_API_KEY set — cryptic error at runtime
agent = Agent("openai:gpt-4o", output_type=str)
result = agent.run_sync("Hello")
# Error: openai.AuthenticationError: No API key provided
Right:
import os
from pydantic_ai import Agent
# Verify the key exists before creating the agent
assert os.environ.get("OPENAI_API_KEY"), "Set OPENAI_API_KEY in your environment"
agent = Agent("openai:gpt-4o", output_type=str)
result = agent.run_sync("Hello")
print(result.output)
Why this matters: PydanticAI uses the standard provider SDK environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY). If they’re missing, the error comes from the underlying SDK — not from PydanticAI — which can be confusing. Check early, fail fast. Use python-dotenv or a .env file in development.
Mistake 3: Not Defining Field Descriptions in Output Models
Wrong:
from pydantic import BaseModel
from pydantic_ai import Agent
class Analysis(BaseModel):
score: float # No description — the LLM guesses what "score" means
tags: list[str] # What kind of tags? Sentiment? Category? Keyword?
agent = Agent("openai:gpt-4o", output_type=Analysis)
Right:
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class Analysis(BaseModel):
sentiment_score: float = Field(
ge=-1.0, le=1.0,
description="Sentiment from -1.0 (very negative) to 1.0 (very positive)",
)
category_tags: list[str] = Field(
min_length=1, max_length=5,
description="Content category labels like 'tech', 'finance', 'health'",
)
agent = Agent("openai:gpt-4o", output_type=Analysis)
Why: PydanticAI sends your model’s JSON Schema (including field descriptions and constraints) to the LLM as part of the structured output instructions. Vague field names without descriptions force the model to guess — and guesses lead to inconsistent outputs. Descriptive fields with constraints act as a contract between your code and the LLM.
Wrap-up
PydanticAI brings the reliability of Pydantic validation to AI agent development. You define structured output types with constraints, add tools with full type safety, inject runtime dependencies cleanly, and swap models without touching your business logic — all in idiomatic Python that your IDE understands.
The key pattern: define your BaseModel, create an Agent with output_type, add @agent.tool functions for actions, and pass deps at runtime for production context. The framework handles retries, validation, and model communication.
Your next step: try replacing a raw OpenAI SDK call in one of your projects with a PydanticAI agent. Start with a simple structured output — even just extracting name/email/phone from text — and see how much validation code you can delete.
No comments yet. Be the first to leave a comment!