Python & AI Tutorials Logo
LangChain & LangGraph

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:

python
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 stock

The 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:

python
# 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:

  1. 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.

  1. LLM responses are unpredictable: The same prompt can produce different formats every time
  2. String parsing is harder than it looks: You need to handle $, spaces, newlines, commas, and more
  3. No type safety at all: You can't be sure if price is a float, string, or None
  4. Error handling is difficult: If the LLM says "Price not available", your float() call crashes
  5. 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:

python
# 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.

Manual Parsing

Structured Output

Breaks Often

Type Safe

LLM Text Output

Brittle String Logic

Typed Python Object

Runtime Errors

Reliable Code

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:

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

This 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:

  1. Define: Create a schema with a Pydantic class
  2. Bind: Connect the schema to the LLM using .with_structured_output()
  3. Invoke: Call .invoke() to get a typed object

This is the standard template you'll use for most structured extraction tasks:

python
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)  # True

What Just Happened?

  1. Schema Definition: We defined the fields and types we want
  2. Binding: .with_structured_output(ProductInfo) configures the LLM to use structured output
  3. Invocation & Response: When .invoke() is called, LangChain passes the JSON Schema to the LLM, and the LLM responds with JSON matching that structure
  4. Auto Conversion: LangChain converts the JSON to a ProductInfo object - 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:

python
# Regular Pydantic - description is documentation for humans
class User(BaseModel):
    name: str = Field(description="User's name")  # Works fine without it

But when working with LLMs, they're essential:

python
# 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:

python
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)      # high

How descriptions shape LLM decisions:

  • sentiment description → LLM learns the valid values are 'positive', 'negative', 'neutral' → Shipping issue caused missed deadline, so chooses 'negative'
  • main_issue description → LLM is instructed to "find the primary complaint" → Identifies "slow shipping" as the problem
  • urgency description → LLM learns urgency must be 'low', 'medium', or 'high' → Sees "Please respond immediately" and chooses 'high'

What happens without descriptions?

python
sentiment: str  # No description

The 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:

python
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:

python
result.sentiment = "Positive"    # Capitalized
result.sentiment = "NEGATIVE"    # All caps
result.sentiment = "good"        # Different word entirely

Writing defensive code:

python
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 values

Key takeaway:

  1. Specify allowed values in description → LLM more likely to respond correctly
  2. 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:

python
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:

python
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:

  1. No Parsing Logic: The structured version has zero parsing code
  2. Type Safety: result.price is guaranteed to be a float
  3. Simpler Code: No regex, no string splitting, no manual type conversion
  4. Validation: Pydantic ensures all required fields are present
  5. 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.

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

When 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:

  1. The LLM cannot find the information in the prompt
  2. The LLM omits that field from its response
  3. LangChain cannot create a valid ProductInfo instance
  4. ValidationError is 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:

python
from typing import Optional
 
class ProductReview(BaseModel):
    rating: int
    review_text: str
    reviewer_name: Optional[str] = None  # Anonymous reviews have no reviewer name

Note: Python 3.10+ users can use str | None instead of Optional[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:

python
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:

  1. LLM omits required field values → ValidationError occurs
  2. 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:

python
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, ValidationError will 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:

  1. Prompt is missing required information
  2. LLM generates plausible-looking values anyway
  3. ValidationError does NOT occur
  4. Your Python application processes fabricated data as if it were real

Example:

python
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 fabricated

This 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.

python
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_validator is Pydantic's decorator that adds custom validation logic
  • mode='after' means validation runs after all fields have been parsed
  • If any field is None, we raise ValueError to signal incomplete data
  • Pydantic automatically wraps this ValueError in a ValidationError

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