Python That Agent Frameworks Assume

Pydantic Models

Pydantic: Describing The Shape Of Data

If you learn one library beyond plain Python for agent work, make it this one.

LangChain, LangGraph, CrewAI, Google ADK, FastAPI and OpenAI's own SDK all lean on Pydantic. Once you can read a Pydantic model, a large amount of framework code stops looking mysterious.

The problem it solves

A language model returns text. You want structured data. Every frustrating afternoon in agent work happens in the gap between those two facts.

Here is the gap, in code. The model replies with something like this:

reply = '{"amount": "1500", "reason": "damaged item"}'

Without Pydantic you write this, and then write it again for every field, on every project:

import json

data = json.loads(reply)

amount = float(data["amount"])          # it came back as text
if amount < 0:
    raise ValueError("amount cannot be negative")

reason = data.get("reason", "")         # might be missing
approved = data.get("approved", False)  # definitely missing

print(amount, reason, approved)
1500.0 damaged item False

That works. It is also five lines of defensive plumbing for three fields, and you will write it again tomorrow.

The same thing with Pydantic

You declare the shape once:

from pydantic import BaseModel

class RefundRequest(BaseModel):
    amount: float
    reason: str
    approved: bool = False

And then:

request = RefundRequest(amount="1500", reason="damaged item")

print(request.amount, type(request.amount))
print(request.reason)
print(request.approved)
1500.0 <class 'float'>
damaged item
False

Three things happened for free:

  • "1500" was converted to the number 1500.0
  • approved defaulted to False
  • if amount had been nonsense, it would have refused — see below

How to read a model

class RefundRequest(BaseModel):
    amount: float
    reason: str
    approved: bool = False

Read it as a table of fields:

LineMeans
amount: floatrequired, must be a number
reason: strrequired, must be text
approved: bool = Falseoptional, defaults to False

One rule tells you almost everything: a field with a default is optional; a field without one is required.

When the data is wrong

from pydantic import ValidationError

try:
    RefundRequest(amount="not a number", reason="x")
except ValidationError as e:
    print(e)
1 validation error for RefundRequest
amount
  Input should be a valid number, unable to parse string as a number

It names the model, the field and the problem.

That matters more than it looks. In an agent loop you can feed that message straight back to the model as a correction, and it will usually fix its own output. You cannot do that with a KeyError.

Field: adding a description the model reads

from pydantic import BaseModel, Field

class SearchInput(BaseModel):
    query: str = Field(description="The search terms, in plain English")
    limit: int = Field(default=5, ge=1, le=20, description="How many results")
PartDoes
descriptionbecomes part of the schema the model sees
defaultused when the field is absent
ge / legreater-or-equal, less-or-equal

Treat descriptions as instructions to the model, not comments to yourself. A vague description produces vague arguments.

Seeing the schema

This is the part that makes it click. Your class becomes a machine-readable description:

import json
print(json.dumps(SearchInput.model_json_schema(), indent=2))
{
  "properties": {
    "query": {
      "description": "The search terms, in plain English",
      "title": "Query",
      "type": "string"
    },
    "limit": {
      "default": 5,
      "description": "How many results",
      "maximum": 20,
      "minimum": 1,
      "title": "Limit",
      "type": "integer"
    }
  },
  "required": ["query"],
  "title": "SearchInput",
  "type": "object"
}

That JSON is exactly what gets sent to the model when your class is used as a tool input. Your field descriptions are sitting right there in it.

Run that one line once. It explains tool calling better than any diagram.

Where it shows up in agent code

Describing what a tool accepts

from langchain_core.tools import tool

class WeatherInput(BaseModel):
    city: str = Field(description="City name, for example Hyderabad")
    unit: str = Field(default="celsius", description="celsius or fahrenheit")

@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius") -> str:
    """Get the current weather for a city."""
    return f"28 degrees {unit} in {city}"

Forcing the model to answer in a fixed shape

This is the feature that changes how you build agents.

class Triage(BaseModel):
    severity: str = Field(description="One of P1, P2, P3")
    owner: str = Field(description="Team that should take this")
    summary: str

structured = llm.with_structured_output(Triage)
result = structured.invoke("Database is down in production, users cannot log in")

print(result.severity)
print(result.owner)

result is a Triage object, not a blob of text you have to parse and pray over.

No regex. No json.loads. No handling the day it wraps the JSON in a code fence.

If you take one idea from this page, take this one. Most flaky agent code is text parsing that should have been a Pydantic model.

Optional and nested fields

Real API shapes nest. Use one model inside another:

from typing import Optional

class Ticket(BaseModel):
    id: int
    severity: str
    owner: Optional[str] = None

class Response(BaseModel):
    status: str
    ticket: Ticket

data = {"status": "success", "ticket": {"id": 4471, "severity": "P2"}}
parsed = Response(**data)

print(parsed.ticket.severity)
print(parsed.ticket.owner)
P2
None

A trap worth naming: Optional alone does not make a field optional. It makes the type allow None. The = None is what makes it optional.

owner: Optional[str]          # required, but may be None
owner: Optional[str] = None   # may be left out entirely

Getting data in and out

model = RefundRequest(amount=1500, reason="damaged")

model.model_dump()          # -> a dict
model.model_dump_json()     # -> a JSON string
RefundRequest(**some_dict)  # from a dict
RefundRequest.model_validate_json(text)   # from JSON text
RefundRequest.model_json_schema()         # the schema

If you meet older code using .dict(), .json() or parse_obj(), that is Pydantic v1. The stack has moved to v2; the names above are current.

Pydantic or a plain dataclass?

Both appear in agent code:

UseWhen
@dataclassinternal data you created and trust
BaseModelanything crossing a boundary — API responses, tool arguments, model output

Validation costs a little speed. On anything a model produced, it is worth it every time.

Coming from Java or C#

This will feel familiar, and the comparison is fair.

Java / C#Pydantic
Shape of dataPOJO / DTO with gettersclass X(BaseModel)
CheckingBean Validation, @NotNullbuilt in
JSON mappingJackson, System.Text.Jsonbuilt in
Schemagenerated by a toolmodel_json_schema()
When it checksoften never, unless wired upalways, on construction

The difference that matters: a Pydantic model validates every time you build one, including from untrusted input like a model's reply. It is closest to a DTO with Bean Validation switched on by default.

Common mistakes

  • No description on fields a model fills. It will guess, and guess badly.
  • Optional[str] with no = None, expecting the field to be optional.
  • Mixing v1 and v2 syntax.dict() versus .model_dump().
  • Parsing model output by hand when with_structured_output would have guaranteed the shape.
  • Over-modelling. You do not need a model for a two-key dictionary you built yourself.

Practise this

  • Write a Member model — a name, a tier defaulting to "silver", and an optional city. Create one with and without the city.
  • Pass a number as text and confirm it is converted.
  • Trigger a ValidationError and read the message carefully.
  • Print model_json_schema() and find your own field descriptions inside it.
  • Model a nested API response you have seen at work, using two classes.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# Pydantic describes the SHAPE of data, and checks it every time.
2# Colab already has pydantic. Elsewhere: pip install pydantic
3
4from typing import Optional
5from pydantic import BaseModel, Field, ValidationError
6
7
8class RefundRequest(BaseModel):
9 amount: float = Field(description="Refund amount in rupees")
10 reason: str
11 approved: bool = False # has a default -> optional
12
13
14# Creating one
15request = RefundRequest(amount=1500, reason="damaged item")
16print(request)
17print(request.amount, request.reason, request.approved)
18print()
19
20# It CONVERTS where it sensibly can
21converted = RefundRequest(amount="1500", reason="late delivery")
22print("string became a number:", converted.amount, type(converted.amount))
23print()
24
25# It REFUSES where it cannot
26try:
27 RefundRequest(amount="not a number", reason="x")
28except ValidationError as e:
29 print("ValidationError:")
30 print(e)
31print()
32
33# Missing a required field
34try:
35 RefundRequest(amount=100)
36except ValidationError as e:
37 print("missing field:", e.errors()[0]["loc"], e.errors()[0]["msg"])
38print()
39
40# In and out
41print("to dict :", request.model_dump())
42print("to json :", request.model_dump_json())
43print("from dict:", RefundRequest(**{"amount": 99, "reason": "test"}))
44print()
45
46# THE SCHEMA - this is what gets sent to the model
47class SearchInput(BaseModel):
48 query: str = Field(description="Search terms, in plain English")
49 limit: int = Field(default=5, ge=1, le=20, description="How many results")
50
51import json
52print("schema the model sees:")
53print(json.dumps(SearchInput.model_json_schema(), indent=2))
54print()
55
56# Nesting, for real API shapes
57class Ticket(BaseModel):
58 id: int
59 severity: str
60 owner: Optional[str] = None # Optional AND a default = truly optional
61
62class Response(BaseModel):
63 status: str
64 ticket: Ticket
65
66data = {"status": "success", "ticket": {"id": 4471, "severity": "P2"}}
67parsed = Response(**data)
68print(parsed.ticket.severity, "| owner:", parsed.ticket.owner)
69
70# This is what `llm.with_structured_output(Triage)` gives you:
71# a real object, not text you have to parse and hope about.