7. Structured Output with Pydantic
In previous chapters, we've been working with LLM outputs as raw text strings. This works fine for chatbots where humans read the responses, but when building AI agents where programs need to parse and interpret LLM outputs, we need predictable, structured data. In this chapter, you'll learn how to use Pydantic schemas to make the LLM return structured Python objects.
7.1) Why Structured Output?
The Problem with Free-Text LLM Output
Let's start by understanding why raw text responses create problems in real applications. Consider this common scenario:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# Ask the LLM about a product
message = HumanMessage(content="""
Extract the product information from this text:
"The UltraWidget Pro costs $299.99 and is currently in stock."
""")
response = llm.invoke([message])
print(response.content)Output:
Product Name: UltraWidget Pro
Price: $299.99
Availability: In stockThe output looks good. But now suppose you need to use this data in your Python application. How do you extract the price as a number? How do you check availability programmatically? You might try string parsing like this:
# Brittle parsing approach
text = response.content
price_line = [line for line in text.split('\n') if 'Price:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # Fragile - what if format changes?This parsing seems to work. But it actually doesn't. Here's why:
Why This Approach Fails:
- The LLM might format the response differently next time ("Price: 299.99 USD" or "Retail Price: $299.99")
Here are examples of different outputs that can occur for the same prompt:
# Example 1
"The product is UltraWidget Pro, priced at $299.99, and it's available."
# Example 2
"Product: UltraWidget Pro
Cost: 299.99 dollars
Status: Available"
# Example 3
"UltraWidget Pro - $299.99 (in stock)"
# Example 4
"I found the UltraWidget Pro. It costs $299.99 and is currently available for purchase."When the LLM response changes, you need completely different parsing logic. This makes it hard to build reliable applications.
- LLM responses are unpredictable: The same prompt can produce different formats every time
- String parsing is harder than it looks: You need to handle
$, spaces, newlines, commas, and more - No type safety at all: You can't be sure if
priceis a float, string, or None - Error handling is difficult: If the LLM says "Price not available", your
float()call crashes - Unmaintainable: Change the prompt slightly and you rewrite all parsing code
The Core Idea: Python Needs Contracts, Not Prose
Think about when you communicate with an API server in Python. When you call a specific REST API, you expect it to return a defined JSON response:
# You expect this structure
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}When building AI applications, you need the same principle. LLM output should be returned as data with a defined structure, not as free-form text every time.
Prose vs Contract:
- Prose: Free-form natural text. Good for humans to read, but hard for programs to process.
- Contract: Data with defined structure and types. A promise that "these fields will exist with these types."
Structured output means defining a contract: "LLM, I need exactly these fields, with exactly these types, in exactly this format."
This is where Pydantic comes in. Pydantic is Python's most popular data validation library, and LangChain uses it to receive LLM outputs in structured form.
The Mental Model Shift:
- Before: "LLM, tell me about this product" → Parse unpredictable text
- After: "LLM, respond in a defined format" → Receive structured Python object
This shift from prose to contracts is fundamental to building reliable AI agents. When an agent needs to decide its next action based on LLM responses (e.g., purchase if in stock, register for notification if not), it must receive responses in a defined format.
7.2) Your First Structured Output
In 7.1, we learned why LLMs should respond with defined structure instead of free-form text. Now let's see how to actually implement this.
The key idea: Simply asking the LLM "please respond in this format" isn't enough. You need to define the exact data structure in Python code and have LangChain pass it to the LLM. This defined data structure is called a schema.
What is a Schema?
A schema is a blueprint that defines the structure of data. It specifies:
- What fields must be present
- What type each field should be (string, number, boolean, etc.)
- What constraints apply (optional vs required, valid ranges, etc.)
In Python, we define schemas using Pydantic's BaseModel class. Here's the simplest possible example:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolThis schema says: "A ProductInfo object must have exactly three fields: a product_name (string), a price (float), and an in_stock (boolean)."
The Three-Step Pattern: Define, Bind, Invoke
Using structured output is simple. Just remember three steps:
- Define: Create a schema with a Pydantic class
- Bind: Connect the schema to the LLM using
.with_structured_output() - Invoke: Call
.invoke()to get a typed object
This is the standard template you'll use for most structured extraction tasks:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# Step 1: Define the schema
class ProductInfo(BaseModel):
product_name: str = Field(description="The full product name")
price: float = Field(description="Price in USD")
in_stock: bool = Field(description="Whether product is available")
# Step 2: Bind schema to LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Step 3: Invoke and get typed object
message = HumanMessage(content="""
Extract product information from this text:
"The UltraWidget Pro costs $299.99 and is currently in stock."
""")
result = structured_llm.invoke([message])
# result is now a ProductInfo object, not a string
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # TrueWhat Just Happened?
- Schema Definition: We defined the fields and types we want
- Binding:
.with_structured_output(ProductInfo)configures the LLM to use structured output - Invocation & Response: When
.invoke()is called, LangChain passes the JSON Schema to the LLM, and the LLM responds with JSON matching that structure - Auto Conversion: LangChain converts the JSON to a
ProductInfoobject - no parsing code needed
No parsing. No type conversion. No errors.
Use this 3-step pattern as your template. Follow it whenever you need structured output.
Field Descriptions: The Key to Guiding the LLM
In the schema definition example above, we used Field(description="..."). This description is not just documentation. It's instructions that the LLM reads and follows.
In typical Pydantic usage, Field descriptions are optional:
# Regular Pydantic - description is documentation for humans
class User(BaseModel):
name: str = Field(description="User's name") # Works fine without itBut when working with LLMs, they're essential:
# With LLMs - description determines LLM behavior
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Overall sentiment: 'positive', 'negative', or 'neutral'"
)The LLM reads this description and uses it to decide how to respond.
Let's see this in action:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Overall sentiment: 'positive', 'negative', or 'neutral'"
)
main_issue: str = Field(
description="The primary complaint or concern, if any. Use 'none' if no issues mentioned."
)
urgency: str = Field(
description="How urgent is the issue: 'low', 'medium', or 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
Analyze this customer feedback:
"The product works fine, but shipping took 3 weeks. I've already missed my project deadline. Please respond immediately."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # Slow shipping
print(result.urgency) # highHow descriptions shape LLM decisions:
sentimentdescription → LLM learns the valid values are 'positive', 'negative', 'neutral' → Shipping issue caused missed deadline, so chooses 'negative'main_issuedescription → LLM is instructed to "find the primary complaint" → Identifies "slow shipping" as the problemurgencydescription → LLM learns urgency must be 'low', 'medium', or 'high' → Sees "Please respond immediately" and chooses 'high'
What happens without descriptions?
sentiment: str # No descriptionThe LLM might return "negative", "bad", "unsatisfied", "2/5", "disappointed" in unpredictable formats, making it hard for your code to handle the values.
Key point: Field descriptions are part of your code that controls LLM behavior. Write them clearly and specifically.
Categorical Fields: Specifying Allowed Values
In the example above, the sentiment field can only have three values: 'positive', 'negative', or 'neutral'. Fields that must be one of a specific set of values are called categorical fields.
For categorical fields, list all possible values in the description:
sentiment: str = Field(
description="Sentiment: exactly 'positive', 'negative', or 'neutral' (lowercase)"
)By specifying "exactly" and "(lowercase)", we emphasize that the LLM should respond with precisely one of these three values.
However, there's no guarantee the LLM will always respond with one of the specified values. That's why you must write defensive code.
Cases where the LLM returns unexpected values:
result.sentiment = "Positive" # Capitalized
result.sentiment = "NEGATIVE" # All caps
result.sentiment = "good" # Different word entirelyWriting defensive code:
allowed = {"positive", "negative", "neutral"}
# Convert to lowercase and check
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # Use default for unexpected values
# Now sentiment is guaranteed to be one of the allowed valuesKey takeaway:
- Specify allowed values in description → LLM more likely to respond correctly
- Validate in code → Handle unexpected values safely
Note: Chapter 18 shows stronger patterns using Python enums for enforcement.
Comparison: Manual Parsing vs Structured Output
Let's compare the same task with and without structured output to see the difference:
Manual Parsing:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
Extract the product name, price, and availability from:
"The UltraWidget Pro costs $299.99 and is currently in stock."
Format: name | price | availability
""")
response = llm.invoke([message])
text = response.content
# Manual parsing
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'in stock' in availability or 'available' in availability
print(f"Name: {product_name}")
print(f"Price: ${price}")
print(f"In Stock: {in_stock}")Structured Output:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="The full product name")
price: float = Field(description="Price in USD")
in_stock: bool = Field(description="Whether product is available")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extract product information from:
"The UltraWidget Pro costs $299.99 and is currently in stock."
""")
result = structured_llm.invoke([message])
print(f"Name: {result.product_name}")
print(f"Price: ${result.price}")
print(f"In Stock: {result.in_stock}")Key Differences:
- No Parsing Logic: The structured version has zero parsing code
- Type Safety:
result.priceis guaranteed to be a float - Simpler Code: No regex, no string splitting, no manual type conversion
- Validation: Pydantic ensures all required fields are present
- Maintainability: Changing the schema is easier than updating parsing logic
7.3) Schema Design Considerations
Now that you know how to use structured output, let's learn how to design good schemas. This section covers practical design principles for distinguishing required from optional fields.
Required Fields
By default, all fields in a Pydantic model are required. This means the LLM must extract or infer a value for every required field from the user's prompt and provide it in the response.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolWhen you use this schema, the LLM will attempt to find values for all three fields (product_name, price, in_stock) in the input text.
But what happens when the prompt lacks information for a required field?
We might expect the following behavior:
- The LLM cannot find the information in the prompt
- The LLM omits that field from its response
- LangChain cannot create a valid
ProductInfoinstance ValidationErroris raised
However, this doesn't always happen.
The reason is that different LLMs may handle missing information differently.
Some LLMs (such as OpenAI models) tend to generate values even when the required information is not present in the prompt. In this case, ValidationError does not occur, but this can cause bigger problems because your Python application may process fabricated information as if it were real.
We'll address how to solve this problem in Section 7.4: When Things Go Wrong.
For now, just be aware that not all LLMs handle missing information the same way.
Optional Fields
You may need fields that can legitimately be present or absent, even in normal cases. For example, a delivery note (delivery_note) may or may not be provided by the customer, even for a valid order.
When to use Optional:
- The data itself may not exist (e.g., when anonymous reviews are allowed, anonymous reviews have no reviewer name)
- You want the LLM to explicitly indicate missing information rather than fabricate a value
To make a field optional, use Python's Optional type from the typing module:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # Anonymous reviews have no reviewer nameNote: Python 3.10+ users can use
str | Noneinstead ofOptional[str].
When a field is Optional:
- The LLM can omit it from the response if the information is not found in the prompt
- Omitted fields are set to the default value (
None)
Here's a complete example:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extract product info: "The UltraWidget Pro costs $299.99 and is in stock."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (not mentioned)
print(result.warranty_years) # None (not mentioned)Schema Design Checklist
Before finalizing your schema, ask yourself:
Field Selection:
- Are required fields truly essential? (What happens if this field is missing from the prompt?)
- Can optional fields legitimately be absent even in normal cases?
Field Specification:
- Does each field have a clear description?
- Are categorical fields explicitly constrained? (e.g., "must be exactly 'A', 'B', or 'C'")
7.4) When Things Go Wrong
Two problems can occur when using structured output:
- LLM omits required field values → ValidationError occurs
- LLM fabricates missing information → No ValidationError, but your code processes incorrect data
This section covers how to handle each.
Understanding Validation Errors
When the user prompt lacks information for required fields defined in the schema, the LLM cannot provide values for those fields. The Python program then raises a ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# Input missing required information
message = HumanMessage(content="""
Extract product info from: "The widget is great! Highly recommended."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("ValidationError occurred")Note: When the prompt lacks information for required fields, some LLMs may fabricate values and provide them in the response. In this case,
ValidationErrorwill not occur, but a bigger problem arises. We'll cover this in the next section.
When the prompt lacks information for required fields and ValidationError occurs, this is actually helpful for your Python application. The application can detect that a problem occurred and handle the error in a controlled way. Error recovery strategies are covered in Chapter 14 (agent-level error recovery) and Chapter 17 (retry logic with state management).
The Bigger Problem: LLM Fabricating Missing Information
As we discussed in Section 7.3, some LLMs exhibit more dangerous behavior: they fabricate values and provide them in responses when information is missing from the prompt.
How the problem occurs:
- Prompt is missing required information
- LLM generates plausible-looking values anyway
ValidationErrordoes NOT occur- Your Python application processes fabricated data as if it were real
Example:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
Extract product info from: "The widget is great!"
""")
# When some LLMs fabricate values for product_name, price, and in_stock
result = structured_llm.invoke([message])
# No error raised!
print(result.product_name) # "widget" (extracted from text)
print(result.price) # 0.0 (fabricated!)
print(result.in_stock) # False (fabricated!)
# Problem: You can't tell which values are real vs fabricatedThis is worse than a ValidationError because:
- Your Python application continues executing with bad data
- You don't know which fields are real vs fabricated
- Downstream logic may make wrong decisions based on fake data
Solution: Use Optional Fields with Validation
The solution is to define all required fields as Optional, then use a validator to check that all required fields have values.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# These are actually required, but declared as Optional
# Real validation happens in the validator below
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""Validate that all essential fields are present"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("All fields (product_name, price, in_stock) must be provided")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Test with incomplete data
message = HumanMessage(content="""
Extract product info from: "The widget is great!"
""")
try:
result = structured_llm.invoke([message])
# If we get here, all fields are guaranteed to be present
print(f"Product: {result.product_name}")
print(f"Price: ${result.price}")
except ValidationError as e:
# Required fields are missing - extraction failed
print(f"Incomplete extraction: {e}")What's happening here:
@model_validatoris Pydantic's decorator that adds custom validation logicmode='after'means validation runs after all fields have been parsed- If any field is
None, we raiseValueErrorto signal incomplete data - Pydantic automatically wraps this
ValueErrorin aValidationError
Why this works:
When the prompt is missing information for fields:
- The LLM does not fabricate values and omits those fields from the response
- In this case, those fields become
None - If those fields are actually required, the Pydantic validator raises
ValueError - Pydantic wraps it as
ValidationError
This way, your Python application receives an explicit error to handle, rather than fabricated data.
Key takeaway: When the prompt lacks information for required fields, getting a ValidationError is perfectly normal and expected. The real danger is fabricated data. Use Optional fields with validators to prevent the LLM from fabricating missing information, while explicitly detecting when required fields are missing.
Chapter Summary:
In this chapter, you learned how to transform LLM output into reliable Python objects:
- Why it matters: Free-text parsing is fragile; schema-based output provides type safety
- How to use it: Define schemas with Pydantic
BaseModel→ Bind with.with_structured_output() - Design principles: Choose required vs optional fields, guide LLM with field descriptions
- Handle problems: ValidationError is normal; real danger is fabricated data