PydanticAI Pydantic Python 9 min read

PydanticAI Guide 2025: Type-Safe Python AI Agent Framework

PydanticAI is the agent framework from the Pydantic team — the same people who built Python's most popular data validation library. Instead of string outputs and hidden magic, PydanticAI agents return validated Pydantic models, use plain Python decorators for tools, and inject dependencies through a typed RunContext. It supports OpenAI, Anthropic, Gemini, Groq, Mistral, and Ollama.

What Is PydanticAI?

PydanticAI was created by Samuel Colvin (author of Pydantic) with a single goal: bring Pydantic's type-safety philosophy to AI application development. While frameworks like LangChain introduced their own DSLs and chain abstractions, PydanticAI keeps everything in plain Python. An Agent is just a Python object. Tools are decorated functions. Results are Pydantic models validated at runtime.

Key design principles: strict Python types end-to-end, no custom DSL, production-grade error handling, built-in dependency injection via RunContext, and first-class support for async. Built on Pydantic v2.

Install PydanticAI

pip install pydantic-ai

To install with a specific provider's extra dependencies: pip install "pydantic-ai[anthropic]" or pip install "pydantic-ai[groq]".

Install and First Agent

Create an Agent with a model string and an optional system_prompt. Call agent.run_sync() for synchronous code or await agent.run() in async contexts:

first_agent.py

from pydantic_ai import Agent

agent = Agent(
    model="openai:gpt-4o",
    system_prompt="You are a concise technical assistant.",
)

result = agent.run_sync("What is the difference between a list and a tuple in Python?")
print(result.data)
# -> "Lists are mutable; tuples are immutable. ..."

The model string follows the pattern provider:model-name. Supported prefixes: openai:, anthropic:, google-gla:, groq:, mistral:, ollama:.

Structured Output with result_type

The killer feature of PydanticAI: set result_type to a Pydantic model and the agent returns a validated instance — not a raw string. The LLM is automatically instructed to return JSON matching the schema, and Pydantic validates it at runtime:

structured_output.py

from pydantic import BaseModel
from pydantic_ai import Agent

class CodeReview(BaseModel):
    summary: str
    issues: list[str]
    severity: str  # "low" | "medium" | "high"
    approve: bool

agent = Agent(
    model="anthropic:claude-3-5-sonnet-latest",
    result_type=CodeReview,
    system_prompt="You are a senior code reviewer.",
)

result = agent.run_sync(
    "Review this function: def add(a, b): return a + b"
)

review = result.data  # type: CodeReview
print(review.approve)   # True
print(review.severity)  # "low"
print(review.issues)    # []

If the LLM returns invalid JSON or the schema doesn't validate, PydanticAI automatically retries with the validation error included in the next prompt — up to a configurable retries count.

Tools / Function Calling with @agent.tool

Decorate a function with @agent.tool to register it as a callable tool. The first argument must be RunContext (for dependency access); the remaining arguments become the tool's JSON schema parameters. Both sync and async tools are supported:

tools.py

import httpx
from pydantic_ai import Agent, RunContext

agent = Agent(
    model="openai:gpt-4o",
    system_prompt="You can look up current weather.",
)

@agent.tool
async def get_weather(ctx: RunContext[None], city: str) -> str:
    """Return current temperature for a city."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"https://wttr.in/{city}?format=%t"
        )
        return resp.text.strip()

import asyncio
result = asyncio.run(agent.run("What's the weather in Kyiv?"))
print(result.data)  # "Current temperature in Kyiv is 18°C"

Use @agent.tool_plain when you don't need RunContext — the first argument then becomes the first tool parameter directly.

Dependency Injection via RunContext

Set deps_type on the Agent to declare what dependencies tools receive. Pass the actual dependency at call time via the deps parameter. This eliminates hidden globals and makes testing trivial — just pass a mock:

dependency_injection.py

from dataclasses import dataclass
import asyncpg
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    db: asyncpg.Connection
    user_id: int

agent = Agent(
    model="openai:gpt-4o",
    deps_type=Deps,
    system_prompt="Answer questions about the user's orders.",
)

@agent.tool
async def get_orders(ctx: RunContext[Deps]) -> list[dict]:
    """Fetch recent orders for the current user."""
    rows = await ctx.deps.db.fetch(
        "SELECT id, total FROM orders WHERE user_id = $1",
        ctx.deps.user_id,
    )
    return [dict(r) for r in rows]

# Inject real deps at call time
async def main(conn, uid):
    deps = Deps(db=conn, user_id=uid)
    result = await agent.run("Show my last 3 orders", deps=deps)
    print(result.data)

Streaming Responses

Use agent.run_stream() as an async context manager to iterate over text tokens in real time. This is useful for chat UIs where you want to display tokens as they arrive:

streaming.py

import asyncio
from pydantic_ai import Agent

agent = Agent(
    model="groq:llama-3.3-70b-versatile",
    system_prompt="You are a helpful assistant.",
)

async def stream_response():
    async with agent.run_stream("Explain async/await in Python") as stream:
        async for text in stream.stream_text(delta=True):
            print(text, end="", flush=True)
        print()  # newline at end

asyncio.run(stream_response())

Pass delta=True to receive incremental chunks; omit it to receive the accumulated text so far on each iteration. The final validated result is available as await stream.get_data() after the loop.

Multi-Turn Conversation

PydanticAI tracks message history on each RunResult. Pass message_history=result.new_messages() to the next call to continue the conversation with full context:

multi_turn.py

from pydantic_ai import Agent

agent = Agent(
    model="anthropic:claude-3-5-haiku-latest",
    system_prompt="You are a Python tutor.",
)

# Turn 1
result1 = agent.run_sync("What is a list comprehension?")
print(result1.data)

# Turn 2 — passes history so the agent remembers turn 1
result2 = agent.run_sync(
    "Can you show me an example?",
    message_history=result1.new_messages(),
)
print(result2.data)

# Turn 3 — full 3-turn context
result3 = agent.run_sync(
    "Now show me a nested one.",
    message_history=result2.new_messages(),
)
print(result3.data)

result.new_messages() returns only the messages added in that run. result.all_messages() returns the complete history including the original input.

PydanticAI vs LangChain vs LlamaIndex vs Instructor

Framework Type safety Agent loops Streaming Ecosystem Complexity
PydanticAI Excellent (Pydantic v2) Built-in Yes Growing Low
LangChain Weak (string-heavy) LangGraph Yes Huge High
LlamaIndex Moderate Yes (AgentRunner) Yes Large (RAG focus) Medium
Instructor Excellent (Pydantic) None (extraction only) Partial Small Very low

Choose PydanticAI when you want full agent loops with type-safe output and clean dependency injection. Choose Instructor when you only need structured extraction from a single LLM call. Choose LangChain when you need its broad integration ecosystem.

Testing with TestModel and FunctionModel

PydanticAI ships two test-only models that avoid any network calls. TestModel returns a canned response. FunctionModel lets you write a Python function that decides the response, enabling you to verify which tools the agent calls and what prompts it sends:

test_agent.py

import pytest
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel, FunctionModel
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart

class Answer(BaseModel):
    text: str
    confidence: float

agent = Agent(result_type=Answer)

@agent.tool_plain
def get_context(topic: str) -> str:
    """Look up context for a topic."""
    return f"Context about {topic}"

# --- Test 1: TestModel returns a fixed canned response ---
def test_agent_with_test_model():
    with agent.override(model=TestModel()):
        result = agent.run_sync("Tell me about Python.")
    # TestModel returns a valid JSON matching result_type schema
    assert isinstance(result.data, Answer)

# --- Test 2: FunctionModel — verify tool was called ---
def test_agent_calls_tool():
    called_with = []

    def my_model(messages: list[ModelMessage], info) -> ModelResponse:
        # First call: instruct the agent to call get_context
        if not called_with:
            return ModelResponse(parts=[
                # Simulate a tool call
            ])
        # Second call: return final answer
        return ModelResponse(parts=[TextPart('{"text": "ok", "confidence": 0.9}')])

    with agent.override(model=FunctionModel(my_model)):
        result = agent.run_sync("Tell me about Python.")
    assert result.data.confidence == pytest.approx(0.9)

Use agent.override(model=TestModel()) as a context manager to swap the model for the duration of a test without modifying the agent definition.

Monitor Your AI APIs in Real Time

PydanticAI agents call OpenAI, Anthropic, and Groq under the hood. Prismix monitors all of them in real time — so when an API goes down or degrades, you know before your users do.

Monitor AI API Status →