Pydantic and Pydantic-AI: Type Safety That Actually Earns Its Keep

Apr 9, 2025Β·
Derek Armstrong portrait
Derek Armstrong
Β· 8 min read

There’s a specific kind of Python bug I’ve learned to dread: your function receives a string where it expected an integer, somewhere at the edge of your system, and six stack frames later something explodes in a way that takes twenty minutes to trace back to the actual source. You add a print statement. You find the string. You fix the caller. Then three weeks later it happens again in a different place because nothing was actually enforcing anything β€” the type hints were decorative.

That frustration is the reason Pydantic exists, and it’s worth understanding properly.

🎯 Key Takeaways

  • Pydantic turns Python type hints into runtime enforcement β€” it’s not documentation, it actually runs and catches bad data at the boundary
  • FastAPI basically dragged Pydantic into the mainstream β€” if you use FastAPI, you’re already using it whether you’ve thought about it or not
  • The V1 β†’ V2 migration was genuinely painful β€” if you hit compatibility issues, you weren’t doing it wrong, the transition just wasn’t smooth
  • Pydantic-AI brings that same discipline to AI agent output β€” structured, validated responses instead of raw strings you have to defensively parse yourself
  • The dependency injection model in Pydantic-AI is what makes it production-viable β€” it keeps testability intact when your agent needs real services

🧱 What Pydantic Actually Does

At its core, Pydantic gives you a BaseModel class. You subclass it, annotate your fields with Python type hints, and Pydantic handles validation at instantiation time β€” coercing types where it safely can, raising detailed errors where it can’t. Simple example:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

That’s not just documentation. Try to construct a User with id="not-an-int" and Pydantic raises a ValidationError immediately, with a message that tells you exactly which field failed and why. That’s the core value proposition: your data either conforms to the contract, or it fails loudly at the boundary β€” not silently and mysteriously somewhere downstream.

It also handles nested structures, which is where things get genuinely useful in real systems:

from typing import List, Optional
from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    zip_code: Optional[str] = None

class User(BaseModel):
    id: int
    name: str
    email: str
    age: Optional[int] = None
    addresses: List[Address]

Pydantic validates the entire object graph, including that addresses is actually a list of valid Address objects. It also handles JSON deserialization, schema generation, and serialization back out β€” so you get the full data lifecycle in one place.

Aside: The reason Pydantic has the market share it does comes down to FastAPI. FastAPI chose Pydantic as its validation backbone, and FastAPI became wildly popular. Suddenly everyone writing Python APIs had Pydantic in their dependency tree whether they’d specifically chosen it or not. That’s not a knock β€” it’s a genuine vote of confidence from a widely-used framework β€” but it’s worth knowing the history so you understand why Pydantic documentation and Stack Overflow answers are so easy to find.

βœ… What Pydantic Gets Right

Validation messages that are actually useful. When Pydantic fails, it tells you what field failed, what value it received, and what was expected. Compare that to “TypeError: expected int, got str” with no context about where in your object the problem is.

IDE support that actually works. Because everything is built on Python type hints, your IDE can autocomplete model fields, catch type mismatches before you run anything, and navigate to field definitions. This isn’t a small thing β€” models become self-documenting in a way that docstrings alone never quite manage.

Coercion where it’s sensible. Pydantic will convert "42" to 42 for an int field rather than rejecting it outright. Whether that’s a feature or a footgun depends on whether you’re parsing user input or enforcing strict internal contracts. You can tighten this with model_config = ConfigDict(strict=True) when you need it.

JSON round-tripping. model.model_dump_json() and Model.model_validate_json(raw_json) just work. For API work, this is the feature you’ll use constantly.

⚠️ Where Pydantic Hurts

The V1 to V2 migration. Let’s be honest about this one. The Pydantic project made significant breaking changes between V1 and V2, and the migration path wasn’t particularly smooth. Decorator names changed, validator syntax changed, some behavior changed. Libraries that depended on V1 had to explicitly pin to it or rush out updated versions β€” and for a while, you’d regularly hit dependency conflicts where one library wanted pydantic<2 and another was already on V2. That specific ugliness has largely settled down now, but if you’re maintaining an older codebase, this is probably why Pydantic is in your list of things you don’t want to touch.

Custom validation complexity. Basic validators on individual fields are straightforward. Multi-field validators β€” where the validity of one field depends on the value of another β€” are documented, but the documentation doesn’t make it obvious which pattern to reach for. This is the area where I’ve spent the most time re-reading docs to figure out why my validator wasn’t being called when I expected.

It adds structure, which costs something. Pydantic isn’t free. There’s overhead at validation time, and more importantly, there’s conceptual overhead β€” you’re adding a layer between your raw data and your application logic. For quick scripts or internal-only code that never touches external data, that layer might not be earning its keep. Know what you’re adding it for.

πŸ€– Pydantic-AI: The Part I’m Actually Interested In

The Pydantic team extended their validation discipline into a direction that makes a lot of sense: AI agent outputs. Pydantic-AI is a Python agent framework specifically designed to give you structured, validated responses from language model calls rather than raw strings you have to parse defensively.

The library supports OpenAI, Anthropic, Gemini, Ollama, Groq, Cohere, and Mistral β€” so you’re not locked into a single provider. And the core idea is exactly what you’d expect from the Pydantic lineage: define the shape you want the AI to return, and let the framework handle enforcement.

Here’s the minimal version:

from pydantic_ai import Agent

agent = Agent(
    'google-gla:gemini-1.5-flash',
    system_prompt='Be concise, reply with one sentence.',
)

result = agent.run_sync('Where does "hello world" come from?')
print(result.data)
# The first known use of "hello, world" was in a 1974 textbook about the C programming language.

Simple enough. Where it gets interesting is when you pair it with a structured result type and real dependencies:

from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

class SupportResult(BaseModel):
    support_advice: str
    block_card: bool
    risk: int  # 1-10

class SupportDependencies(BaseModel):
    customer_id: int
    db: "DatabaseConn"

support_agent = Agent(
    'openai:gpt-4o',
    deps_type=SupportDependencies,
    result_type=SupportResult,
    system_prompt=(
        "You are a support agent in our bank. Give the customer support "
        "and assess the risk level of their query."
    ),
)

@support_agent.system_prompt
async def add_customer_name(ctx: RunContext[SupportDependencies]) -> str:
    customer_name = await ctx.deps.db.customer_name(id=ctx.deps.customer_id)
    return f"The customer's name is {customer_name!r}"

@support_agent.tool
async def customer_balance(ctx: RunContext[SupportDependencies], include_pending: bool) -> float:
    """Returns the customer's current account balance."""
    return await ctx.deps.db.customer_balance(
        id=ctx.deps.customer_id,
        include_pending=include_pending,
    )

A few things worth calling out here. First, the result_type=SupportResult means you get a validated Pydantic model back, not a string. The agent either returns something that matches that schema or it retries β€” you don’t land in a situation where you’re writing defensive parsing code on the output. Second, the dependency injection system means your agent’s tools get access to real services through a typed context object, which keeps unit testing sane. You can inject a mock DatabaseConn in tests and the same agent code runs without modification.

That’s the bit that makes Pydantic-AI feel different from “dump stuff into a prompt and hope.” It’s designed around production use from the start.

πŸ—ΊοΈ When To Reach For It (And When Not To)

Use Pydantic when:

  • You’re taking data from any external source β€” APIs, user input, config files, webhooks β€” anything that can arrive in a shape you didn’t control
  • You’re building with FastAPI (it’s already there, learn to use it well)
  • Your models are complex enough that manual validation would be error-prone or verbose
  • You want schema generation for documentation or OpenAPI specs

Skip it when:

  • You’re writing a quick script that processes data you fully control
  • Performance is genuinely critical and profiling shows Pydantic overhead is a factor
  • You’re prototyping something throwaway β€” adding the validation layer before the shape of your data is stable is friction you don’t need yet

Use Pydantic-AI when:

  • You’re building agents that need to return structured, reliable output rather than free-form text
  • You need your agent to call real services and you want dependency injection rather than global state
  • You’re targeting multiple LLM providers and don’t want to rewrite agent logic for each one

Think twice if:

  • You just need to call an LLM and display the text response β€” this is a lot of framework for a requests.post()
  • Your team isn’t comfortable with Python type system concepts β€” Pydantic-AI leans into generics and typed contexts, and that’s not a great starting point for a team still getting comfortable with type hints

πŸ”— Further Reading

If this saved you some time figuring out the landscape, pass it along.