Build a Type-Safe AI Agent with PydanticAI
PydanticAI is a Python agent framework built by the team behind Pydantic — the most widely used data validation library in Python. If you have ever wanted your AI agent to return reliably structured data instead of free-form text, PydanticAI is designed exactly for that use case.
The framework is model-agnostic (supporting OpenAI, Anthropic, Gemini, Ollama, and more), puts Python's type system at the center of agent design, and makes agents as easy to test as regular Python functions through dependency injection.
In this tutorial you will build a job listing analyzer that takes unstructured job description text and extracts structured information: required skills, salary range, experience level, and a suitability score — all fully typed and validated.
What You'll Learn#
- How to install PydanticAI and configure model backends
- How to define structured output types using Pydantic
BaseModel - How to register tools on agents and use
RunContextfor dependency injection - How to write unit tests for agents without making real API calls
- How to stream structured results incrementally
Prerequisites#
- Python 3.10 or higher installed
- An API key for at least one supported model (OpenAI, Anthropic, or Google)
- Basic understanding of AI agents and Pydantic models
- Familiarity with Python type hints and dataclasses
Step 1: Project Setup#
mkdir pydantic-agent-demo && cd pydantic-agent-demo
python -m venv .venv && source .venv/bin/activate
# Install PydanticAI with your preferred model backend
pip install 'pydantic-ai[openai]' python-dotenv
# Or for Anthropic:
# pip install 'pydantic-ai[anthropic]'
# Or for Gemini:
# pip install 'pydantic-ai[google-generativeai]'
Create .env:
OPENAI_API_KEY=sk-...your-key...
Step 2: Define Your Output Schema#
This is where PydanticAI shines. Instead of asking the model for free-form text, you define exactly what you want back using Pydantic models. PydanticAI handles schema generation and validation automatically.
# schemas.py
from pydantic import BaseModel, Field
from typing import Optional
class SkillRequirement(BaseModel):
skill: str = Field(description="Name of the required skill or technology")
level: str = Field(description="Required proficiency level: beginner, intermediate, or expert")
is_required: bool = Field(description="True if mandatory, False if nice-to-have")
class SalaryRange(BaseModel):
min_usd: Optional[int] = Field(None, description="Minimum annual salary in USD, or None if not specified")
max_usd: Optional[int] = Field(None, description="Maximum annual salary in USD, or None if not specified")
currency: str = Field(default="USD", description="Currency code")
class JobAnalysis(BaseModel):
"""Structured analysis of a job listing."""
job_title: str = Field(description="Extracted job title")
company: Optional[str] = Field(None, description="Company name if mentioned")
experience_years_min: int = Field(description="Minimum years of experience required")
experience_years_max: Optional[int] = Field(None, description="Maximum years or None if open-ended")
skills: list[SkillRequirement] = Field(description="List of required and nice-to-have skills")
salary: SalaryRange = Field(description="Extracted salary range")
is_remote: bool = Field(description="True if remote work is explicitly mentioned")
seniority_level: str = Field(description="One of: junior, mid, senior, staff, principal, director")
summary: str = Field(description="One-paragraph summary of the role in plain language")
suitability_score: float = Field(
ge=0.0,
le=10.0,
description="Overall suitability score from 0-10 based on clarity and completeness of the listing",
)
Step 3: Create the Agent with Dependency Injection#
PydanticAI uses a dependency injection pattern via RunContext. This lets you pass in external dependencies (database connections, HTTP clients, user data) at runtime, making agents easy to test by swapping out real dependencies with mocks.
# agent.py
import os
from dataclasses import dataclass
from dotenv import load_dotenv
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIModel
from schemas import JobAnalysis
load_dotenv()
@dataclass
class AgentDeps:
"""Dependencies injected into the agent at runtime."""
user_profile: dict # The candidate's own profile for suitability scoring
min_salary_threshold: int = 80_000 # User's minimum acceptable salary
# Create the agent: model + output type + dependency type
job_agent = Agent(
model=OpenAIModel("gpt-4o"),
result_type=JobAnalysis, # The agent MUST return this type
deps_type=AgentDeps,
system_prompt="""You are an expert technical recruiter who analyzes job listings.
Extract all structured information from the job description provided.
For suitability_score, rate how well-written and complete the listing is (not candidate fit).
Be precise with salary extraction — only fill in values explicitly stated in the text.""",
)
@job_agent.tool
async def get_market_salary(
ctx: RunContext[AgentDeps],
job_title: str,
location: str = "Remote",
) -> dict:
"""Look up typical market salary ranges for a given job title and location.
Args:
ctx: Run context with injected dependencies.
job_title: The job title to look up (e.g., 'Senior Python Developer').
location: City or region for location-adjusted data.
Returns:
A dict with median, p25, and p75 annual salary in USD.
"""
# In production, call a salary API like Levels.fyi, Glassdoor, or Payscale
# Mock implementation for tutorial
salary_data = {
"senior python developer": {"median": 155_000, "p25": 130_000, "p75": 185_000},
"machine learning engineer": {"median": 175_000, "p25": 145_000, "p75": 210_000},
"ai engineer": {"median": 180_000, "p25": 150_000, "p75": 220_000},
"data scientist": {"median": 140_000, "p25": 115_000, "p75": 170_000},
}
key = job_title.lower()
data = salary_data.get(key, {"median": 120_000, "p25": 95_000, "p75": 150_000})
return {
"job_title": job_title,
"location": location,
"market_salary_usd": data,
"user_minimum_usd": ctx.deps.min_salary_threshold,
"meets_minimum": data["median"] >= ctx.deps.min_salary_threshold,
}
Notice that the tool receives ctx: RunContext[AgentDeps] as its first argument. This gives the tool access to the injected dependencies — in this case, the user's salary threshold — without needing globals or closures.
Step 4: Run the Agent#
# run.py
import asyncio
from agent import job_agent, AgentDeps
JOB_LISTING = """
Senior AI Engineer at TechCorp (Remote)
We're looking for a Senior AI Engineer to join our growing team. You'll be building
production ML systems and LLM-powered applications.
Requirements:
- 5+ years of Python experience
- 3+ years working with machine learning frameworks (PyTorch, TensorFlow)
- Experience with LLM APIs (OpenAI, Anthropic)
- Strong knowledge of MLOps practices (Docker, Kubernetes, CI/CD)
- Experience with vector databases (Pinecone, Weaviate) is a plus
Compensation: $160,000 - $220,000 + equity + benefits
This is a fully remote position. We offer flexible hours and a async-first culture.
"""
async def main():
deps = AgentDeps(
user_profile={"name": "Alice", "years_experience": 6},
min_salary_threshold=150_000,
)
result = await job_agent.run(
user_prompt=f"Analyze this job listing:\n\n{JOB_LISTING}",
deps=deps,
)
analysis: JobAnalysis = result.data # Fully typed, validated output
print(f"Job Title: {analysis.job_title}")
print(f"Seniority: {analysis.seniority_level}")
print(f"Experience: {analysis.experience_years_min}+ years")
print(f"Remote: {analysis.is_remote}")
print(f"Salary: ${analysis.salary.min_usd:,} - ${analysis.salary.max_usd:,}")
print(f"\nRequired Skills:")
for skill in analysis.skills:
req_marker = "[REQUIRED]" if skill.is_required else "[NICE TO HAVE]"
print(f" {req_marker} {skill.skill} ({skill.level})")
print(f"\nSuitability Score: {analysis.suitability_score}/10")
print(f"\nSummary: {analysis.summary}")
if __name__ == "__main__":
asyncio.run(main())
Step 5: Writing Tests Without API Calls#
One of PydanticAI's best features is its TestModel — a fake model that lets you test agent logic without spending tokens or hitting rate limits.
# test_agent.py
import pytest
from pydantic_ai import models
from pydantic_ai.models.test import TestModel
from agent import job_agent, AgentDeps
from schemas import JobAnalysis, SalaryRange
@pytest.mark.asyncio
async def test_job_agent_returns_valid_schema():
"""Test that the agent returns a valid JobAnalysis even with a test model."""
# TestModel returns minimal valid data matching the result_type schema
with models.override(job_agent, TestModel()):
deps = AgentDeps(user_profile={}, min_salary_threshold=100_000)
result = await job_agent.run("Analyze this listing: Senior Dev, 3+ years Python.", deps=deps)
# PydanticAI guarantees result.data matches JobAnalysis
assert isinstance(result.data, JobAnalysis)
assert isinstance(result.data.salary, SalaryRange)
assert 0.0 <= result.data.suitability_score <= 10.0
@pytest.mark.asyncio
async def test_tool_receives_deps():
"""Test that tools can access injected dependencies."""
received_threshold = None
@job_agent.tool
async def capture_threshold(ctx, job_title: str) -> str:
nonlocal received_threshold
received_threshold = ctx.deps.min_salary_threshold
return "captured"
with models.override(job_agent, TestModel(custom_result_text='{"job_title":"test"}')):
deps = AgentDeps(user_profile={}, min_salary_threshold=200_000)
await job_agent.run("analyze", deps=deps)
assert received_threshold == 200_000
Run with:
pip install pytest pytest-asyncio
pytest test_agent.py -v
Step 6: Streaming Structured Results#
For long-running jobs, you can stream the structured result incrementally using agent.run_stream():
async def stream_example():
deps = AgentDeps(user_profile={}, min_salary_threshold=100_000)
async with job_agent.run_stream(
f"Analyze: {JOB_LISTING}",
deps=deps,
) as stream:
# Stream partial text as it arrives
async for text in stream.stream_text():
print(text, end="", flush=True)
# Get the fully validated final result
final: JobAnalysis = await stream.get_data()
print(f"\n\nFinal validated result: {final.job_title}")
What's Next#
You now have a production-grade agent with guaranteed typed outputs and clean test coverage. Recommended next steps:
- Compare approaches: See the PydanticAI vs LangChain comparison for when to choose each framework
- Explore the directory: Check out the PydanticAI directory entry for advanced configuration options
- LangChain alternative: Learn building an agent with LangChain for a more ecosystem-focused approach
- Multi-agent systems: See building with CrewAI for structured role-based multi-agent teams
- Understand agent patterns: Read about ReAct reasoning to understand how your agent decides when to call tools
Frequently Asked Questions#
Which model providers does PydanticAI support?
PydanticAI supports OpenAI, Anthropic, Google Gemini (via google-generativeai), Mistral, Cohere, Groq, and any OpenAI-compatible endpoint. It also supports local models via Ollama. Each provider has a corresponding extras package: pydantic-ai[anthropic], pydantic-ai[google-generativeai], etc.
What happens if the model returns data that fails Pydantic validation?
PydanticAI automatically retries the model call with the validation error message appended to the conversation, giving the model a chance to fix its output. You can configure the number of retries via result_retries on the Agent constructor.
Can I use PydanticAI without structured outputs?
Yes. If you set result_type=str (the default), the agent behaves like a standard chat agent returning plain text. You can also use result_type=list[str] or any JSON-serializable type.
How does dependency injection differ from passing arguments directly?
Dependencies are injected at run() time and are available to all tools in that run via RunContext.deps. This means tools don't need to accept extra parameters — they get what they need from context. This makes it easy to swap implementations for testing without changing any tool code.
Does PydanticAI support multi-agent workflows?
Yes, via the Agent.run() call from within a tool or via explicit agent orchestration. The framework includes experimental multi-agent coordination, and the team has indicated first-class multi-agent support is on the roadmap for mid-2026.