Introduction to Structured Output with Pydantic and LangChain
Large language models are powerful, but their outputs are inherently unstructured. When you build production applications, you rarely want free-form text — you want typed data that your downstream code can safely consume. This is where combining Pydantic with LangChain becomes essential. Pydantic gives you a declarative way to define schemas, and LangChain provides the orchestration layer that prompts the model, parses the response, and validates it against your schema.
What Is Pydantic?
Pydantic is the most widely used data validation library in the Python ecosystem. It lets you define data models as standard Python classes that inherit from BaseModel. Each field is annotated with a type, and Pydantic automatically validates incoming data, coerces compatible types, and produces rich error messages when validation fails. Because Pydantic models serialize cleanly to JSON Schema, they are the perfect bridge between Python code and LLM tool-calling APIs.
What Is LangChain's Structured Output Feature?
LangChain exposes a method called with_structured_output() on chat model objects. When you pass a Pydantic class (or a JSON Schema dict, or a TypedDict) to this method, LangChain returns a new runnable that guarantees the model's response will conform to your schema. Under the hood, LangChain chooses the best available strategy — native tool-calling, JSON mode, or function-calling — depending on the model provider you are using.
Why Structured Output Matters
Working with raw LLM text is fragile. If you ask a model to "return JSON," it may wrap the JSON in markdown fences, add conversational preamble, or invent fields that don't exist in your schema. Each of these failures forces you to write brittle parsing logic with regex and try/except blocks. Structured output eliminates this entire class of problems.
- Type safety: Your downstream functions receive validated Python objects, not strings.
- Reduced hallucination: Constraining the model to a schema narrows the output space and reduces invented fields.
- Composability: Structured outputs can be passed directly into databases, APIs, or other LangChain chains.
- Better debugging: When validation fails, Pydantic tells you exactly which field and why.
- Provider abstraction: The same Pydantic schema works across OpenAI, Anthropic, Google, and open-source models.
Setting Up Your Environment
Before writing code, install the required packages. The example below uses OpenAI, but the same patterns apply to any LangChain-supported chat model.
pip install langchain langchain-openai pydantic
Set your API key as an environment variable:
export OPENAI_API_KEY="sk-your-key-here"
Defining a Pydantic Schema
The first step is to define the shape of the data you want the model to produce. Always include Field descriptions — they serve as in-context documentation that guides the model toward correct values.
from pydantic import BaseModel, Field
from typing import List, Optional
class Person(BaseModel):
"""Information about a person mentioned in a text."""
name: str = Field(description="The full name of the person")
age: Optional[int] = Field(
default=None,
description="The person's age, if mentioned. Omit if unknown."
)
occupation: Optional[str] = Field(
default=None,
description="The person's job or profession, if mentioned."
)
class ExtractionResult(BaseModel):
"""The result of extracting person information from a text."""
people: List[Person] = Field(
description="A list of every person mentioned in the source text"
)
Notice how every field carries a description. These descriptions are converted into the JSON Schema that the model sees, so they directly influence output quality. Treat them as part of your prompt engineering.
Binding the Schema to a Chat Model
Once your schema is defined, bind it to a chat model using with_structured_output(). The returned object behaves like a normal LangChain runnable: you call invoke() with a message and receive a fully validated Pydantic instance.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(ExtractionResult)
text = """
Sarah Chen is a 34-year-old software engineer who recently joined a startup
called Nimbus. Her colleague, Marcus Webb, is the head of product and
has been in the industry for over a decade.
"""
result = structured_llm.invoke(text)
print(type(result)) # <class '__main__.ExtractionResult'>
print(result.people[0].name) # Sarah Chen
print(result.people[0].age) # 34
print(result.people[1].occupation) # head of product
The model never returns a raw string here. LangChain handles the tool-call or JSON-mode negotiation with the provider, parses the response, and instantiates your Pydantic model. If the model returns data that violates the schema, Pydantic raises a ValidationError immediately.
Using Structured Output Inside a Chain
Structured output becomes even more powerful when composed with other LangChain components. Using LCEL (LangChain Expression Language), you can build a pipeline that takes raw text, applies a prompt template, and returns a typed object.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are an expert information extraction engine."),
("human", "Extract all people mentioned in the following text:\n\n{text}"),
])
chain = prompt | llm.with_structured_output(ExtractionResult)
result = chain.invoke({"text": text})
for person in result.people:
print(f"{person.name} — age: {person.age}, role: {person.occupation}")
Because the chain ends with a structured output runnable, every call to invoke, batch, or stream returns validated ExtractionResult objects. This makes the chain safe to drop into a larger application — a FastAPI endpoint, a Celery task, or an Airflow DAG.
Handling Enums and Constrained Values
Real-world schemas often need constrained values such as categories or severity levels. Pydantic's Enum support works seamlessly with LangChain's structured output, forcing the model to choose from a fixed set of options.
from enum import Enum
class Sentiment(str, Enum):
positive = "positive"
neutral = "neutral"
negative = "negative"
class SentimentAnalysis(BaseModel):
"""The sentiment analysis result for a single piece of text."""
sentiment: Sentiment = Field(
description="The overall sentiment of the text"
)
confidence: float = Field(
description="A confidence score between 0.0 and 1.0",
ge=0.0,
le=1.0,
)
reasoning: str = Field(
description="A short explanation of why this sentiment was chosen"
)
analyzer = llm.with_structured_output(SentimentAnalysis)
review = "The food was incredible but the service was painfully slow."
analysis = analyzer.invoke(f"Analyze this review: {review}")
print(analysis.sentiment) # Sentiment.neutral
print(analysis.confidence) # 0.75
print(analysis.reasoning)
The ge and le constraints on confidence are translated into the JSON Schema, so the model is informed of the valid range. If it returns 1.5, Pydantic rejects it before your code ever sees the value.
Nested Models and Complex Schemas
Pydantic models can nest arbitrarily deep, and LangChain handles this without extra configuration. This is useful for tasks like extracting structured data from documents with hierarchical relationships.
class Address(BaseModel):
street: str = Field(description="Street address")
city: str = Field(description="City name")
postal_code: Optional[str] = Field(
default=None, description="Postal or ZIP code if available"
)
class Company(BaseModel):
name: str = Field(description="The company's legal name")
industry: str = Field(description="Primary industry or sector")
headquarters: Address = Field(description="The company's main office")
employee_count: Optional[int] = Field(
default=None,
description="Approximate number of employees"
)
class CompanyReport(BaseModel):
"""A structured report about a company extracted from text."""
company: Company
summary: str = Field(
description="A one-paragraph summary of the company"
)
key_facts: List[str] = Field(
description="A list of notable facts about the company"
)
reporter = llm.with_structured_output(CompanyReport)
source = """
Acme Robotics is an autonomous-vehicle startup based at 120 Innovation Way,
Palo Alto, CA 94301. Founded in 2019, the company now employs roughly 280
people and focuses on last-mile delivery robots.
"""
report = reporter.invoke(source)
print(report.company.name) # Acme Robotics
print(report.company.headquarters.city) # Palo Alto
print(report.company.employee_count) # 280
print(report.key_facts)
Nesting is one of the strongest arguments for using Pydantic over plain JSON Schema dictionaries. You get IDE autocompletion, static type checking, and runtime validation all from the same class definitions.
Choosing the Right Method: Tool Calling vs JSON Mode
LangChain's with_structured_output() accepts a method parameter that controls how the model is constrained. Understanding the trade-offs helps you avoid subtle bugs.
method="function_calling"(default for most providers): Uses the provider's native tool-calling API. Supports the richest schemas, including nested objects and enums. This is the recommended choice when available.method="json_mode": Tells the model to respond with valid JSON. Simpler and sometimes faster, but the model may not strictly respect every constraint. Best for flat schemas or when tool-calling is unavailable.method="json_schema": Available on newer OpenAI models, this constrains the model at the decoding level using JSON Schema. Offers the strongest guarantees but is provider-specific.
# Explicitly choose a method
structured_llm = llm.with_structured_output(
ExtractionResult,
method="json_schema",
)
If you omit the method argument, LangChain selects the best option for your model provider automatically. In most cases the default is fine, but being explicit is helpful when you are debugging edge cases.
Best Practices
Write Descriptive Field Documentation
Field descriptions are part of the prompt. A vague description like "the name" produces worse results than "The full legal name of the person, including first and last name". Be specific about format, allowed values, and what to do when information is missing.
Use Optional Fields for Uncertain Data
LLMs frequently encounter text where some information is absent. Marking fields as Optional with a default=None prevents validation errors and lets your code handle missing data gracefully rather than crashing the entire pipeline.
Keep Schemas Focused
Avoid dumping every possible field into a single giant model. Large schemas confuse models and increase the chance of hallucinated or mis-typed values. If you need to extract many different kinds of information, consider running multiple focused chains in parallel instead of one monolithic extraction.
Set Temperature to Zero for Extraction
For deterministic extraction and classification tasks, set temperature=0. This reduces variability between runs and makes your structured output more reproducible, which is critical for testing and evaluation.
Validate and Retry on Failure
Even with structured output, occasional validation failures can occur, especially with weaker models. Wrap your invocation in a retry loop or use LangChain's with_retry() method to add resilience.
from langchain_core.runnables import RunnableLambda
robust_chain = (
prompt
| llm.with_structured_output(ExtractionResult)
).with_retry(stop_after_attempt=3)
result = robust_chain.invoke({"text": text})
Test Schemas Independently
Because Pydantic models are plain Python classes, you can unit-test them without involving the LLM at all. Construct sample dictionaries, pass them through ExtractionResult.model_validate(), and assert that validation behaves as expected. This catches schema design issues long before you spend tokens on the model.
Conclusion
Combining Pydantic with LangChain's structured output transforms LLM responses from unpredictable text into reliable, typed Python objects. By defining clear schemas with descriptive fields, choosing the right binding method, and following best practices around optionality, schema focus, and retries, you can build extraction, classification, and reasoning pipelines that are robust enough for production. The small upfront cost of designing a good Pydantic model pays off every time your downstream code can trust the shape of the data it receives — no regex parsing, no defensive try/except blocks, just clean and validated objects flowing through your application.