Pydantic AI: Build Type-Safe AI Agents in Python
Pydantic AI is the newest bridge between Python’s powerful data validation library and the exploding world of generative AI. By leveraging Pydantic’s type‑safe models, you can turn raw LLM output into well‑structured Python objects, catching errors before they reach production. In this guide we’ll walk through installing the library, building a simple type‑safe agent, and scaling the pattern to real‑world scenarios like a customer‑support chatbot. Whether you’re a seasoned data engineer or a curious hobbyist, you’ll leave with concrete code you can copy‑paste into your own projects.
What is Pydantic AI?
Pydantic AI is an extension that wraps Large Language Model (LLM) calls with Pydantic’s BaseModel validation. Instead of receiving a free‑form string, you define a schema—think of it as a contract—that the LLM must satisfy. The library parses the model’s JSON response, validates it against the schema, and raises informative errors if anything is missing or malformed. This approach eliminates the guesswork that typically accompanies prompt engineering and makes AI‑driven pipelines as reliable as any traditional API.
Core concepts
- Schema‑first design: Write Pydantic models before you write prompts.
- Typed responses: LLM outputs are automatically converted to Python objects.
- Validation feedback: Errors are surfaced as
ValidationErrorexceptions you can catch. - Composable agents: Combine multiple models to build complex workflows.
Because the validation happens on the client side, you retain full control over data quality while still benefiting from the creativity of generative models. This is especially valuable in regulated industries where malformed data can cause compliance headaches.
Installing and setting up
The first step is to add the library to your environment. Pydantic AI works with Python 3.9+ and supports the major LLM providers via a unified interface.
# Install the core library and an optional provider (e.g., OpenAI)
pip install pydantic-ai[openai]
# Verify the installation
python -c "import pydantic_ai; print(pydantic_ai.__version__)"
After installation, you’ll need an API key from your chosen provider. Store it securely—environment variables are the simplest approach.
import os
os.environ["OPENAI_API_KEY"] = "sk-...your-key..."
Building your first type‑safe AI agent
Let’s create a minimal agent that extracts a user’s name and favorite color from a free‑form sentence. The magic happens in three steps: define a schema, write a prompt, and call the model through PydanticAI.
from pydantic import BaseModel, Field
from pydantic_ai import PydanticAI
# 1️⃣ Define the expected output schema
class UserPreference(BaseModel):
name: str = Field(..., description="The user's full name")
favorite_color: str = Field(..., description="The user's favorite color")
# 2️⃣ Initialize the AI client (defaults to OpenAI's gpt‑4o)
ai = PydanticAI()
# 3️⃣ Ask the model to fill the schema
def extract_preference(text: str) -> UserPreference:
prompt = f"""
Extract the person's name and favorite color from the following sentence.
Return the result as JSON that matches the schema:
{UserPreference.schema_json()}
Sentence: "{text}"
"""
return ai.run(prompt, response_model=UserPreference)
# Example usage
result = extract_preference("Hi, I'm Maya and I love teal!")
print(result)
# Output: name='Maya' favorite_color='teal'
The run method automatically serializes the prompt, sends it to the LLM, parses the JSON response, and validates it against UserPreference. If the model forgets a field or returns the wrong type, a ValidationError is raised, giving you an immediate signal that the prompt needs refinement.
Defining schemas with Pydantic
Pydantic models can be as simple or as sophisticated as you need. Use field validators, default factories, or even custom types to capture domain‑specific rules.
from pydantic import validator
from datetime import datetime
class Event(BaseModel):
title: str
start_time: datetime
duration_minutes: int
@validator("duration_minutes")
def positive_duration(cls, v):
if v <= 0:
raise ValueError("duration must be positive")
return v
When this model is used with Pydantic AI, the LLM must produce an ISO‑8601 timestamp for start_time and a positive integer for duration_minutes. Any deviation triggers a clear validation message.
Connecting to a different LLM provider
Pydantic AI abstracts the provider layer, so swapping from OpenAI to Anthropic or Cohere is a one‑liner.
from pydantic_ai.providers import AnthropicProvider
anthropic = PydanticAI(provider=AnthropicProvider(model="claude-3-5-sonnet"))
response = anthropic.run("Summarize the plot of 'The Little Prince'.", response_model=str)
print(response)
This flexibility is handy for cost optimization, latency testing, or simply leveraging a provider’s unique strengths (e.g., Claude’s instruction following).
Real‑world use case: Customer Support Bot
Imagine you need a support assistant that can triage tickets, extract key details, and route them to the correct department. Type safety ensures that every ticket is logged with a consistent structure, reducing manual clean‑up downstream.
Step 1: Model the conversation
class SupportTicket(BaseModel):
ticket_id: str = Field(..., description="Unique identifier, e.g., UUID")
issue_type: str = Field(..., description="One of: 'billing', 'technical', 'account'")
priority: str = Field(..., description="One of: 'low', 'medium', 'high'")
description: str = Field(..., description="User-provided issue description")
customer_email: str = Field(..., description="Contact email for follow‑up")
Notice the use of description metadata—Pydantic AI injects these hints into the prompt, guiding the LLM toward the correct output format.
Step 2: Prompt engineering with type safety
def create_ticket(user_message: str) -> SupportTicket:
prompt = f"""
You are a helpful support assistant. Convert the user's message into a structured support ticket.
Follow the JSON schema exactly:
{SupportTicket.schema_json()}
User message: """{user_message}"""
"""
return ai.run(prompt, response_model=SupportTicket)
# Sample interaction
msg = """
Hey, I was double‑charged for my subscription this month.
My account email is jane.doe@example.com. Please fix ASAP!
"""
ticket = create_ticket(msg)
print(ticket)
The resulting SupportTicket instance can be fed directly into your ticketing system (e.g., Zendesk, Freshdesk) without any further parsing.
Pro tip: Include explicit examples in the prompt. A short JSON snippet showing a correctly formatted ticket dramatically improves the model’s adherence to the schema.
Advanced patterns
Once you’re comfortable with single‑model agents, you can compose them into richer pipelines. Below are two patterns that frequently appear in production.
Chaining multiple agents
- First agent extracts raw data (e.g.,
SupportTicket). - Second agent enriches the data (e.g., adds sentiment score).
- Third agent decides on routing logic based on enriched fields.
class EnrichedTicket(SupportTicket):
sentiment: float = Field(..., description="Sentiment score between -1 and 1")
is_urgent: bool = Field(..., description="True if priority is high or sentiment < -0.5")
def enrich_ticket(ticket: SupportTicket) -> EnrichedTicket:
prompt = f"""
Analyze the following support ticket and add a sentiment score.
Also set 'is_urgent' to true if the ticket is high priority or sentiment is strongly negative.
Use this schema:
{EnrichedTicket.schema_json()}
Ticket JSON: {ticket.json()}
"""
return ai.run(prompt, response_model=EnrichedTicket)
# Chain example
raw = create_ticket(msg)
full = enrich_ticket(raw)
print(full)
Each step benefits from Pydantic’s validation, so errors are caught early and isolated to the responsible agent.
Validation hooks and custom validators
Sometimes you need domain‑specific checks that go beyond simple type constraints. Pydantic lets you attach @validator methods, and Pydantic AI will surface any violations as part of the normal exception flow.
class EmailTicket(SupportTicket):
@validator("customer_email")
def must_be_corporate(cls, v):
if not v.endswith("@example.com"):
raise ValueError("Only corporate emails are accepted")
return v
def create_corp_ticket(message: str) -> EmailTicket:
prompt = f"""
Convert the message into a support ticket. Only accept corporate emails.
Schema: {EmailTicket.schema_json()}
Message: """{message}"""
"""
return ai.run(prompt, response_model=EmailTicket)
If the LLM supplies a personal Gmail address, the validator throws a ValidationError, allowing you to ask the user for clarification or fallback to a manual process.
Pro tip: Combine Pydantic’s root_validator with LLM‑generated confidence scores to create adaptive fallback strategies.
Testing and debugging
Because the contract is explicit, unit testing becomes straightforward. Mock the LLM response, feed it to the model, and assert the resulting object.
- Create a fixture with a static JSON payload that matches the schema.
- Patch
PydanticAI.runto return the fixture instead of calling the real API. - Run your function and verify the fields.
import pytest
from unittest.mock import patch
@pytest.fixture
def mock_ticket_json():
return {
"ticket_id": "123e4567-e89b-12d3-a456-426614174000",
"issue_type": "billing",
"priority": "high",
"description": "I was charged twice.",
"customer_email": "john.doe@example.com"
}
def test_create_ticket(mock_ticket_json):
with patch("pydantic_ai.PydanticAI.run") as mock_run:
mock_run.return_value = SupportTicket(**mock_ticket_json)
ticket = create_ticket("I got double‑billed.")
assert ticket.priority == "high"
assert ticket.issue_type == "billing"
This pattern gives you confidence that changes to prompts or schemas won’t silently break downstream logic.
Performance considerations
While Pydantic AI adds a thin validation layer, there are a few performance knobs you can tune.
- Batching: Send multiple prompts in a single API call when the provider supports it.
- Schema size: Keep schemas minimal; larger schemas increase token usage.
- Cache responses: For deterministic prompts (e.g., static data extraction), cache the LLM output to avoid repeat calls.
- Parallelism: Use asyncio or threading to fire off independent agents concurrently.
In practice, the validation overhead is negligible compared to the network latency of the LLM call, but keeping schemas lean can shave a few hundred milliseconds per request.
Conclusion
Pydantic AI empowers you to treat generative AI like any other typed API. By defining schemas up front, you gain predictability, safety, and a clear contract that survives model upgrades and prompt tweaks. The examples above—simple extraction, a full‑featured support bot, and advanced chaining—demonstrate how a few lines of code can replace brittle string parsing with robust, testable Python objects. Adopt the pattern early in your AI projects, and you’ll spend less time firefighting malformed data and more time building delightful user experiences.