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 FalseThat 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 = FalseAnd 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
FalseThree things happened for free:
"1500"was converted to the number1500.0approveddefaulted toFalse- if
amounthad been nonsense, it would have refused — see below
How to read a model
class RefundRequest(BaseModel):
amount: float
reason: str
approved: bool = FalseRead it as a table of fields:
| Line | Means |
|---|---|
amount: float | required, must be a number |
reason: str | required, must be text |
approved: bool = False | optional, 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 numberIt 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")| Part | Does |
|---|---|
description | becomes part of the schema the model sees |
default | used when the field is absent |
ge / le | greater-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
NoneA 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 entirelyGetting 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 schemaIf 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:
| Use | When |
|---|---|
@dataclass | internal data you created and trust |
BaseModel | anything 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 data | POJO / DTO with getters | class X(BaseModel) |
| Checking | Bean Validation, @NotNull | built in |
| JSON mapping | Jackson, System.Text.Json | built in |
| Schema | generated by a tool | model_json_schema() |
| When it checks | often never, unless wired up | always, 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
descriptionon 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_outputwould have guaranteed the shape. - Over-modelling. You do not need a model for a two-key dictionary you built yourself.
Practise this
- Write a
Membermodel — 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
ValidationErrorand 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.