A complete introduction to PydanticAI

PydanticAI is a library for building AI agents that leverages Pydantic’s powerful type validation capabilities. This post is a complete introduction to PydanticAI, exploring how to implement an agent using PydanticAI, with detailed code examples.

A complete introduction to PydanticAI
Photo by Kenny Eliason / Unsplash

Recently, the AI field has been shifting its focus from merely relying on pre-trained knowledge to developing AI agents that can independently utilize external tools, retrieve necessary information, and even collaborate with other AI models to achieve specific goals. But how can we implement an AI agent?

Libraries like LangGraph specialize in managing complex workflows, enabling the development of more flexible AI agent applications. Meanwhile, frameworks such as CrewAI provide a more streamlined approach to executing desired tasks.

In this post, we will explore PydanticAI, an AI agent development framework based on Pydantic, a powerful library for data type validation. Effective communication between AI agents requires well-structured input and output formats, and Pydantic’s concise and efficient type validation system makes development much more efficient.

Now, let's dive into how to use PydanticAI!

Installation

You can easily install PydanticAI using pip:

pip install pydantic-ai

If you're using the uv package manager, you can install it with:

uv add pydantic-ai

Key Concepts of PydanticAI - Agent

The Agent class is the core component of PydanticAI. Conceptually, an agent acts as a container that includes the following components:

  1. System Prompt: Defines the agent’s initial instructions and behavior.
  2. Dependencies: Specifies the required external tools or other agents that the agent relies on.
  3. Structured Result Type: Ensures that the agent's output follows a predefined schema using Pydantic models.
  4. Function Tool: Allows the agent to use additional functions for processing or retrieving information.
  5. LLM Model & Settings: Configures the large language model (LLM) that powers the agent, including parameters like temperature and response limits.

Now, let's explore each component in detail and learn how to define and use an agent in PydanticAI!

Static System Prompt Example

A static system prompt can be defined using the system_prompt parameter when initializing an Agent, as shown in the example below.

from pydantic_ai import Agent

agent = Agent(
    "openai:gpt-4o-mini",
    system_prompt="You are a helpful assistant",
)

result = agent.run_sync("Where does 'hello world' come from?")
print(result.data)
# 'Hello, World!' originated from the 1972 programming language tutorial in 
# the book "The C Programming Language" by Brian Kernighan and Dennis Ritchie.

Dynamic System Prompt Example

If you want to insert runtime-determined elements into the system prompt, you can:

  1. Define a separate function that returns the desired system prompt.
  2. Wrap this function with @agent.system_prompt.

For example, to include the current time in the system prompt, you can do the following:

from pydantic_ai import Agent
from datetime import datetime

agent = Agent(
    "openai:gpt-4o-mini",
)


@agent.system_prompt
def system_prompt():
    return f"You are a helpful assistant. The current date and time is {datetime.now()}"


result = agent.run_sync("What is the current date and time?")
print(result.data)
# The current date and time is January 29, 2025, 20:19:10.162546.

Dependencies

In the PydanticAI framework, dependencies refer to resources or contexts that can be shared throughout the execution of an Agent. These dependencies can include strings, numbers, or arbitrary Python objects.

However, to adhere to PydanticAI's type-safety philosophy, the data structure of dependencies must be predefined. This can be done using a Python dataclass.

For example, if you want to include the current user's information in the system prompt:

  1. Define the dependency data structure as a dataclass (e.g., MyDependency).
  2. Pass this structure to the deps_type parameter when initializing the Agent.
  3. Define a system prompt that accepts a RunContext containing the dependency.
  4. Inject the dependency object when executing the agent using run_sync().

This ensures seamless dependency injection via RunContext throughout the agent's execution cycle. Let's take a look at an example!

from pydantic_ai import Agent, RunContext
from dataclasses import dataclass


@dataclass
class MyDependency:
    name: str
    age: int


agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=MyDependency,  # define dependency type
)


@agent.system_prompt
def system_prompt(ctx: RunContext[MyDependency]):
    return f"""
    You are a helpful assistant for user {ctx.deps.name} who is {ctx.deps.age} years old.
    """

# pass dependency
result = agent.run_sync("Who am I?", deps=MyDependency(name="Bongnam Kim", age=30))
print(result.data)
# You are Bongnam Kim, a 30-year-old individual. 
# If you’d like to share more information about yourself, feel free to let me know!

Structured Result Type

You can enforce the desired data format for the agent's execution results by utilizing Pydantic's data type validation. Simply specify the data format as the result_type parameter when initializing the Agent. You can easily check this with the example below.

from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

# define result type
class Area(BaseModel):
    area: int
    unit: str


# bind two tools
def get_circle_area(radius: float) -> float:
    return radius * radius * 3.14

def get_square_area(side: float) -> float:
    return side * side


agent = Agent(
    "openai:gpt-4o-mini",
    result_type=Area,  # force result type to be `Area`
    system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
    tools=[get_circle_area, get_square_area],
)


result = agent.run_sync("What is the area of a circle with a radius of 10cm?")
print(result.data.area)  # 314
print(result.data.unit)  # cm²


result = agent.run_sync("What is the area of a square with a side of 10cm?")
print(result.data.area)  # 100
print(result.data.unit)  # cm²

By providing tools to calculate the area of a circle and a square, and enforcing the result type as an Area type with properties like area and unit, we can ensure that the agent returns the correct Area object after executing the necessary tools.

Function Tools

The agents we've seen so far were simple, single LLM requests. However, the true power of an agentic workflow lies in the agent's ability to make decisions and use the required tools to gather base information and draw conclusions.

To enable this, we need to give the agent the tools it needs (tool binding). In PydanticAI, tool binding can be done in three ways. Let’s explore examples of each:

  1. Using the @agent.tool decorator: This method is used when the tool execution needs access to context information (like RunContext), similar to dependencies.
  2. Using the @agent.tool_plain decorator: This method is used when the tool execution does not need to depend on RunContext.
  3. Passing a list of Tool objects or functions to the tools parameter: This method doesn't rely on individual agent instance decorators, increasing the reusability of the tool functions.

Tool Binding Using @agent.tool Decorator

Here’s an example where the tool get_user_name retrieves the user’s name stored in the current dependency and returns it along with the answer. Pay attention to the code below:

  • A str type dependency is defined, and in run_sync(), the dependency value "Bongnam Kim" is passed.
  • This value is accessed within the get_user_name function using ctx.deps.
  • The static system prompt always includes the user's name in the response.
  • The get_user_name function is wrapped with the @agent.tool decorator for tool binding.
from pydantic_ai import Agent, RunContext

agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=str,
    system_prompt=("You are a helpful assistant. Answer with the name of the user."),
)


@agent.tool  # binds this tool to the object `agent`
def get_user_name(ctx: RunContext[str]) -> str:
    """Get the user's name."""
    return ctx.deps


result = agent.run_sync("Hi!", deps="Bongnam Kim")
print(result.data)
# Hello, Bongnam Kim! How can I assist you today?

As a result, we can confirm that the user's name, Bongnam Kim, is successfully added to the response.

A natural question that arises is whether the agent actually executed the get_user_name tool to retrieve the user's name, Bongnam Kim. Is it possible to trace the tool execution flow of the model?

Indeed! The result.all_messages() function returns a list of ModelRequest and ModelResponse objects. This list contains logs showing the agent's decision-making process, including which tools were executed and what values were returned by each tool.

[ModelRequest(parts=[SystemPromptPart(content='You are a helpful assistant. '
                                              'Answer with the name of the '
                                              'user.',
                                      dynamic_ref=None,
                                      part_kind='system-prompt'),
                     UserPromptPart(content='Hi!',
                                    timestamp=datetime.datetime(2025, 1, 29, 12, 19, 34, 232313, tzinfo=datetime.timezone.utc),
                                    part_kind='user-prompt')],
              kind='request'),
 ModelResponse(parts=[ToolCallPart(tool_name='get_user_name',
                                   args=ArgsJson(args_json='{}'),
                                   tool_call_id='call_WUJCzFb9xoExxDmP0RTYCvmk',
                                   part_kind='tool-call')],
               model_name='gpt-4o-mini',
               timestamp=datetime.datetime(2025, 1, 29, 12, 19, 34, tzinfo=datetime.timezone.utc),
               kind='response'),
 ModelRequest(parts=[ToolReturnPart(tool_name='get_user_name',
                                    content='Bongnam Kim',
                                    tool_call_id='call_WUJCzFb9xoExxDmP0RTYCvmk',
                                    timestamp=datetime.datetime(2025, 1, 29, 12, 19, 35, 41514, tzinfo=datetime.timezone.utc),
                                    part_kind='tool-return')],
              kind='request'),
 ModelResponse(parts=[TextPart(content='Hello, Bongnam Kim! How can I assist '
                                       'you today?',
                               part_kind='text')],
               model_name='gpt-4o-mini',
               timestamp=datetime.datetime(2025, 1, 29, 12, 19, 35, tzinfo=datetime.timezone.utc),
               kind='response')]

By checking the logs above, you can see the second element ModelResponse(parts=[ToolCallPart(tool_name='get_user_name', args=ArgsJson(Args_json='{}'), ... confirming that the get_user_name tool was actually executed!

Tool Binding Using @agent.tool_plain Decorator

In many cases, tool execution does not require RunContext. For example, consider a simple multiplication tool that helps the agent with calculations. In this case, there’s no need for the tool to access dependencies.

For such cases, you can use the @agent.tool_plain decorator to bind the tool, as shown below:

from pydantic_ai import Agent

agent = Agent(
    "openai:gpt-4o-mini",
    system_prompt=("You are a helpful assistant."),
)


@agent.tool_plain
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b


result = agent.run_sync("What is 1234 + 5678?")
print(result.data)
# 1234 + 5678 equals 6912.

When looking at the logs related to tool execution, it appears as follows. The add tool is used with the arguments a=1234 and b=5678.

 ModelResponse(parts=[ToolCallPart(tool_name='add',
                                   args=ArgsJson(args_json='{"a":1234,"b":5678}'),
                                   tool_call_id='call_FyI5w9Y33lY4l9kVrTZC0fsM',
                                   part_kind='tool-call')],
               model_name='gpt-4o-mini',
               timestamp=datetime.datetime(2025, 1, 29, 12, 31, 48, tzinfo=datetime.timezone.utc),
               kind='response'),

Tool Binding by Passing Tool Objects or Function List to tools Parameter

The decorator-based methods mentioned earlier are effective when a specific tool is implemented and used for a specific agent. However, in many cases, from a reusability perspective, you might want to keep the implementation of the tool (or function) single and have multiple agents use the same tool.

For this, PydanticAI allows tool binding without relying on decorators. Instead, you can simply pass a list of tools (e.g., tools=[tool1, tool2]) during the Agent initialization.

Note: If you are implementing tools that depend on RunContext, remember that the first argument of the tool function must be of type RunContext[XXX].

from pydantic_ai import Agent, RunContext

# tool without RunContext
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

# tool with RunContext
def get_user_name(ctx: RunContext[str]) -> str:
    """Get the user's name."""
    return ctx.deps


agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=str,
    system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
    tools=[add, get_user_name],
)


result = agent.run_sync("What is 1234 + 5678?", deps="Bongnam Kim")
print(result.data)
# The result of 1234 + 5678 is 6912, Bongnam Kim.

LLM Model and Model Settings (LLM model / settings)

PydanticAI is a model-agnostic framework, meaning it does not depend on a specific LLM model type. In other words, you can configure the agent to work with different LLM models interchangeably. The following model providers are supported by PydanticAI:

  • OpenAI
  • Anthropic
  • Gemini (Generative Language API / Vertex API)
  • Ollama
  • Groq
  • Mistral

Settings

If you installed PydanticAI using pip install pydantic-ai or uv add pydantic-ai, you are ready to use all the model providers by default.

However, if you used pip install pydantic-ai-slim or uv add pydantic-ai-slim to install only the necessary packages, you will need to install individual model providers, such as:

  • pip install pydantic-ai-slim[openai]
  • pip install pydantic-ai-slim[anthropic]

For more detailed installation instructions, please refer to the PydanticAI official documentation.

Using OpenAI Model in PydanticAI

  • Environment Variable Setup: To use the OpenAI model in PydanticAI, you need to set the OpenAI API key as an environment variable:
export OPENAI_API_KEY='your-api-key'
  • Usage Example
from pydantic_ai import Agent

# Initialize the agent with OpenAI as the LLM provider
agent = Agent(model="openai")

# Run the agent
response = agent.run("What is the capital of France?")
print(response)

Using DeepSeek V3 Model in PydanticAI

  • Environment Variable Setup: export DEEPSEEK_API_KEY='your-api-key'
  • Usage example
import os
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel

model = OpenAIModel(
    'deepseek-chat',
    base_url='https://api.deepseek.com',
    api_key=os.getenv('DEEPSEEK_API_KEY'),
)
agent = Agent(model)

Using Ollama Model in PydanticAI

  • Using Ollama on the Same Machine
from pydantic_ai import Agent

agent = Agent('ollama:llama3.2')
  • Using a remote Ollama server
from pydantic import BaseModel

from pydantic_ai import Agent
from pydantic_ai.models.ollama import OllamaModel

ollama_model = OllamaModel(
    model_name='llama3.2',
    base_url='http://192.168.1.74:11434/v1',  # remote url
)

agent = Agent(model=ollama_model)

Other Options

Usage Limits with UsageLimits for API Request Limitation

One important thing to keep in mind when building an application with an Agent is that you cannot predict how many API requests the agent will make in advance. If the number of API requests increases, it can result in excessive costs, so it's safer to set usage limits beforehand.

In PydanticAI, you can limit usage by passing a UsageLimits object as the usage_limits parameter when running the Agent.

  • When creating the UsageLimits object, you can define maximum limits for various usage items, such as request_limit, request_tokens_limit, response_tokens_limit, and total_tokens_limit.
  • For example, if you want to limit the number of response tokens to fewer than 10, you can do so as shown below. An error will occur if the number of response tokens exceeds the limit, and the execution will stop immediately.
from pydantic_ai import Agent, RunContext
from pydantic_ai.usage import UsageLimits


agent = Agent(
    "openai:gpt-4o-mini",
    system_prompt=("You are a helpful assistant."),
)

# tool without RunContext
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

# tool with RunContext
def get_user_name(ctx: RunContext[str]) -> str:
    """Get the user's name."""
    return ctx.deps


agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=str,
    system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
    tools=[add, get_user_name],
)


result = agent.run_sync(
  "What is 1234 + 5678?", 
  deps="Bongnam Kim", 
  usage_limits=UsageLimits(response_tokens_limit=10)
)
print(result.data)

Adjusting LLM Model Execution Parameters Using model_settings

You may want to adjust the execution parameters of the LLM that underpins the Agent, such as temperature, timeout, and other settings. In this case, you can use the model_settings parameter during execution to customize these parameters.

from pydantic_ai import Agent

agent = Agent(
    "openai:gpt-4o-mini",
    system_prompt=("You are a helpful assistant"),
)

result = agent.run_sync(
  "What is the capital of France?", 
  model_settings={'temperature': 0.0}
)
print(result.data)  # The capital of France is Paris.

LLM models can only provide information up to the point of their training, meaning they cannot answer questions about newer information created after the model’s training. However, what if we give the agent a web search tool? By searching for the latest information and then using the search results to compose a response, the agent could provide better, up-to-date answers! In this example, we will give the agent a simple web search tool based on DuckDuckGo, and ask it about the Korean national public holidays in 2025.

from pydantic_ai import Agent
from duckduckgo_search import DDGS

# simple web search tool using duckduckgo_search
# this may be replaced with Tavily, Perplexity or else
def duckduckgo_search(query: str) -> str:
    """Search the web for the given query."""
    with DDGS() as ddgs:
        return ddgs.text(query)


agent = Agent(
    "openai:gpt-4o-mini",
    system_prompt=("You are a helpful assistant."),
    tools=[duckduckgo_search],
)


result = agent.run_sync(
    "List all the Korean national holidays in 2025."
)
print(result.data)
# Here are the Korean national holidays for 2025:

# 1. **New Year's Day** - January 1, 2025 (Wednesday)
# 2. **Seollal (Lunar New Year)** - January 28, 2025 (Tuesday) to January 30, 2025 (Thursday)
# 3. **Independence Movement Day** - March 1, 2025 (Saturday)
# 4. **Buddha's Birthday** - May 15, 2025 (Thursday)
# 5. **Memorial Day** - June 6, 2025 (Friday)
# 6. **National Liberation Day** - August 15, 2025 (Friday)
# 7. **Chuseok (Korean Harvest Festival)** - September 29, 2025 (Monday) to October 1, 2025 (Wednesday)
# 8. **National Foundation Day** - October 3, 2025 (Friday)
# 9. **Hangeul Day (Korean Alphabet Day)** - October 9, 2025 (Thursday)
# 10. **Christmas Day** - December 25, 2025 (Thursday)

# Please note that some holidays may be adjusted based on the lunar calendar, and there may be substitute holidays if the date falls on a weekend. 
# For more detailed information, you can check resources like [Public Holidays Korea](https://publicholidays.co.kr/2025-dates/) 
# or the [Korean Calendar site](https://timeanddate.com/holidays/south-korea/2025).

If we take a quick look at how the model used the duckduckgo_search tool, it would look something like this:

 ModelResponse(parts=[ToolCallPart(tool_name='duckduckgo_search',
            args=ArgsJson(args_json='{"query":"Korean national holidays 2025'"}'),
            tool_call_id='call_VjYGMZzYh5JGSQ2BMR6W7a6f',
            part_kind='tool-call')],
        model_name='gpt-4o-mini',
        timestamp=datetime.datetime(2025, 1, 29, 14, 1, 5, tzinfo=datetime.timezone.utc),
               kind='response'),

You can see that the model is using duckduckgo_search('Korean national holidays 2025') to attempt the search. This search query will retrieve the relevant web results, which the model will then use to generate an up-to-date response regarding the national holidays in Korea for 2025.

Conclusion

In this post, we explored the core philosophy and concepts of the PydanticAI library. We discussed how to provide static or dynamic system prompts during agent initialization, how to pass the agent's execution context and dependencies, and how to bind tools to the agent. Hopefully, this gives you a sense of how to implement an agentic framework.

Once you become familiar with the relationship between Agent, RunContext, and Tool in PydanticAI, you'll be able to freely implement various agentic workflows. I, too, am excited to start building with these concepts! 😊