◈Home ▸Blog ◇Contact
Home/Blog/How to Build a Type-Safe AI Agent With Pydant…
How toLLM EngineeringPydantic AIAI AgentsPython

How to Build a Type-Safe AI Agent With Pydantic AI

8 min readBy Miloš Mitrović

Most agent bugs aren't reasoning failures. They're shape failures: the model returns prose where your code expected an object, a string where you needed an integer, or a required field that quietly went missing. Pydantic AI closes that gap. You install the package, point an Agent at a model, declare a Pydantic output_type, and the framework validates every response and asks the model to correct itself when the shape is wrong. This guide walks the full procedure, plus the tool and dependency-injection patterns that make these agents testable.

Key Takeaways

  • Short answer: run pip install pydantic-ai, create Agent('openai:gpt-5.2', output_type=YourModel), call agent.run_sync(prompt), and read the validated result off result.output.
  • Pydantic AI validates every model response against your BaseModel and retries automatically when validation fails; the output retry budget defaults to one attempt and is configurable.
  • Tools are plain Python functions registered with @agent.tool. The schema the model sees is generated from your function signature and docstring, so naming and types matter.
  • Runtime dependencies (database clients, API keys, config) are injected type-safely through deps_type and read inside tools via ctx.deps.
  • Model identifiers use a provider:model string, so you can swap OpenAI, Anthropic, or Gemini without touching agent logic.

What You Need Before You Start

  • Python 3.10 or newer, ideally in a fresh virtual environment.
  • An API key for one provider (OpenAI, Anthropic, or Google), exported as an environment variable.
  • Working knowledge of Pydantic BaseModel and Python type hints. The whole framework leans on them.

How Do You Build a Typed Pydantic AI Agent?

Follow these seven steps end to end. The first three get a working agent in front of you in under a minute; the rest add the structure you'll actually ship.

  1. Install the package. The default install pulls in OpenAI, Anthropic, and Google support:

    pip install pydantic-ai

    If you want a lean dependency tree, install the slim variant and name only the providers you use:

    pip install "pydantic-ai-slim[openai,logfire]"
  2. Set your provider API key. Pydantic AI reads it from the environment. For OpenAI:

    export OPENAI_API_KEY=sk-...

    Use ANTHROPIC_API_KEY or GEMINI_API_KEY instead if you point the agent at those providers.

  3. Write a minimal agent and run it. This confirms your key and install before you add any structure:

    from pydantic_ai import Agent
    
    agent = Agent(
        'openai:gpt-5.2',
        instructions='Be concise, reply with one sentence.',
    )
    
    result = agent.run_sync('Where does "hello world" come from?')
    print(result.output)

    The model string is provider:model. Swap in anthropic:claude-sonnet-4-6 or google:gemini-3-flash-preview and nothing else changes.

  4. Declare a typed output. Replace the free-text reply with a Pydantic model, and pass it as output_type. Now the agent is forced to return data matching this schema:

    from pydantic import BaseModel, Field
    from pydantic_ai import Agent
    
    class SupportOutput(BaseModel):
        advice: str = Field(description="Advice to give the customer")
        block_card: bool = Field(description="Whether to block their card")
        risk: int = Field(description="Risk level of the query", ge=0, le=10)
    
    agent = Agent(
        'openai:gpt-5.2',
        output_type=SupportOutput,
        instructions=(
            "You are a support agent at a bank. Judge the risk of the "
            "customer's query and decide whether to block their card."
        ),
    )
  5. Define your dependencies. Group the runtime objects the agent needs (a database handle, an HTTP client, IDs) into a dataclass, and tell the agent about it with deps_type:

    from dataclasses import dataclass
    
    @dataclass
    class SupportDeps:
        customer_id: int
        db: "DatabaseConn"  # your own async DB wrapper
    
    agent = Agent(
        'openai:gpt-5.2',
        deps_type=SupportDeps,
        output_type=SupportOutput,
        instructions="You are a support agent at a bank.",
    )
  6. Register a tool. Any function the model may call becomes a tool. Take RunContext[SupportDeps] as the first argument to reach your injected dependencies, and write a real docstring, because that text is what the model reads to decide when to call it:

    from pydantic_ai import RunContext
    
    @agent.tool
    async def customer_balance(
        ctx: RunContext[SupportDeps], include_pending: bool
    ) -> float:
        """Return the customer's current account balance."""
        return await ctx.deps.db.balance(
            id=ctx.deps.customer_id,
            pending=include_pending,
        )

    For helpers that need no context, use @agent.tool_plain instead.

  7. Run the agent with deps and read the typed result. Pass an instance of your dependencies to the run call:

    deps = SupportDeps(customer_id=123, db=DatabaseConn())
    result = agent.run_sync('What is my balance, including pending?', deps=deps)
    
    print(result.output)
    # SupportOutput(advice='Your balance is ...', block_card=False, risk=1)
    print(result.output.risk)  # a real int, already validated: 1

That is the complete task. result.output is a fully validated SupportOutput instance, not a string you have to parse and pray over.

How Do You Force the Model to Return Valid Output?

The output_type handles structural validation for free: wrong types, out-of-range ints, and missing fields all trigger an automatic retry where the framework hands the validation error back to the model. For logic that Pydantic can't express, such as "this SQL must actually run," add an output validator and raise ModelRetry:

from pydantic_ai import ModelRetry

@agent.output_validator
async def validate_query(ctx: RunContext[SupportDeps], output: SupportOutput) -> SupportOutput:
    if output.risk > 8 and not output.block_card:
        raise ModelRetry("High-risk queries must block the card. Reconsider.")
    return output

ModelRetry is the key tool here. Raise it from a tool or an output validator with a clear message, and the model gets that message and another attempt. The output retry budget defaults to one; raise it with Agent(..., retries=3) when a task genuinely needs more room to self-correct.

How Does Dependency Injection Keep Agents Testable?

Because every external resource arrives through deps, your agent carries no hidden global state. In production you pass a real DatabaseConn; in a unit test you pass a fake that returns fixed values, and you assert on the typed output without a network call. This is the same reason the pattern pairs well with evals: swap the deps, replay a fixture, check the structured result. If you already lean on structured outputs to get reliable JSON from an LLM, Pydantic AI is that idea promoted to a first-class agent contract rather than a hand-rolled parse step.

When Should You Pick Pydantic AI Over LangGraph or LangChain?

Pydantic AI competes with a few well-known options. They solve overlapping problems with different centers of gravity, so match the tool to the job rather than to the hype.

Capability Pydantic AI LangGraph LangChain Raw provider SDK
Validated typed output Built in via output_type Manual, you validate Manual or add-on parsers Manual JSON parsing
Type-safe dependency injection Built in via deps_type Via graph state Limited None
Multi-step or branching control Tools plus graphs Its core strength Chains and agents You write the loop
Boilerplate to first result Low Medium Medium to high Low but grows fast
Best fit Typed, testable single agents and tools Complex stateful workflows Broad integrations, RAG glue Full control, minimal deps

Reach for Pydantic AI when correctness and testability of a focused agent matter most. When the job is an elaborate branching state machine, read how to build a stateful AI agent with LangGraph and consider that instead; the two also compose.

What Breaks First, and How Do You Fix It?

Troubleshooting Common Errors

  • ImportError or "provider not installed": you used the slim package without the right extra. Install it, for example pip install "pydantic-ai-slim[anthropic]".
  • Repeated validation failures on output: your field descriptions are too vague for the model. Tighten each Field(description=...), add constraints like ge and le, and raise retries if the task legitimately needs more attempts.
  • The model never calls your tool: the tool schema comes from the function signature and docstring. Rename cryptic parameters, add type hints, and write a docstring that states plainly when the tool applies.
  • API key not found: confirm the environment variable name matches the provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY) and that it is exported in the same shell that runs the script.
  • "coroutine was never awaited": you mixed sync and async. Use agent.run_sync(...) in synchronous code, and await agent.run(...) inside an async function.
  • Side effects firing twice while streaming: output validators run on partial outputs too. Check ctx.partial_output and skip irreversible work until the output is final.

What Should You Do Next?

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.