LangChain Structured Output with Pydantic Models
Why "Just Ask Nicely" Doesn't Work in Production
Every LLM engineer hits this wall eventually. You prompt the model with "please return JSON with fields name, age, and email," and it mostly works — until it doesn't. The model wraps the JSON in a markdown code fence. It adds a friendly sentence before the object. It renames email to email_address because that felt more natural. It drops a field entirely on a long context. None of this is malicious; it's just what happens when you ask a next-token predictor to also be a strict data contract.
The moment you try to pipe an LLM's output into a downstream system — a database insert, an API call, a UI component, another function — "mostly works" becomes a production incident. You end up writing regex to strip code fences, try/except blocks around json.loads(), and retry loops that re-prompt the model with "that wasn't valid JSON, try again." It's fragile, it's slow, and it doesn't scale past a demo.
This is exactly the problem LangChain's structured output tooling was built to solve. Combined with Pydantic — Python's de facto standard for data validation — you get a system where you define the shape of the data you want once, as a plain Python class, and the LLM is contractually bound to fill it in correctly. No parsing gymnastics, no brittle regex, no "please respond only in JSON" prompt incantations.
In this article we'll go from the absolute basics of with_structured_output() to advanced patterns: nested models, optional fields, enums, Union types for classification, streaming structured data, and handling the inevitable validation failures gracefully. Every example is real, runnable code — no hand-waving.
What with_structured_output() Actually Does
Before writing code, it helps to understand what's happening under the hood, because it changes how you debug problems later.
When you call model.with_structured_output(YourPydanticModel), LangChain does one of two things depending on the provider:
- Native tool/function calling. For providers that support tool calling (OpenAI, Anthropic, Google, and most modern chat models), LangChain converts your Pydantic model into a JSON Schema and passes it to the model as a "tool" the model is forced to call. The model's response comes back as structured tool-call arguments, which LangChain then validates against your Pydantic model and instantiates directly.
- JSON mode with schema injection. For models without native tool calling, LangChain falls back to injecting the schema into the prompt and asking the model to emit JSON matching it, then parsing that JSON through the Pydantic model.
Either way, the contract is the same from your code's perspective: you get back an instance of your Pydantic model, not a raw string, not a dict you have to sanity-check field by field. If the model's output doesn't match the schema, you get a validation error you can catch — not a silent data corruption bug three services downstream.
This matters because it means with_structured_output() isn't prompt-engineering sugar. It's binding the model's output to a real schema that Pydantic enforces with actual type checking, constraint validation, and default values.
Your First Structured Output: A Minimal Example
Let's start with the smallest possible useful example: extracting structured contact information from unstructured text.
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
class ContactInfo(BaseModel):
"""Contact details extracted from a message."""
name: str = Field(description="Full name of the person")
email: str = Field(description="Email address")
company: str | None = Field(default=None, description="Company name, if mentioned")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(ContactInfo)
result = structured_llm.invoke(
"Hi, I'm Sarah Chen from Northwind Robotics. "
"You can reach me at sarah.chen@northwind.io for the partnership discussion."
)
print(result)
print(type(result))
print(result.name, "-", result.company)Running this gives you back a genuine ContactInfo object:
ContactInfo(name='Sarah Chen', email='sarah.chen@northwind.io', company='Northwind Robotics')
<class '__main__.ContactInfo'>
Sarah Chen - Northwind RoboticsNotice what you *didn't* have to write: no prompt telling the model "respond in JSON," no json.loads(), no field name matching. The Field(description=...) strings do real work here — they get embedded into the JSON Schema sent to the model, so the model understands not just the field name but what you actually want in it. Treat these descriptions as part of your prompt engineering, not just documentation.
Nested Models and Lists for Real-World Data
Real data is rarely flat. Invoices have line items. Resumes have work histories. Support tickets have multiple issues. Pydantic's nested model support maps directly onto this.
from pydantic import BaseModel, Field
class LineItem(BaseModel):
description: str = Field(description="What was purchased")
quantity: int = Field(description="Number of units")
unit_price: float = Field(description="Price per unit in USD")
class Invoice(BaseModel):
vendor_name: str = Field(description="Name of the vendor issuing the invoice")
invoice_number: str = Field(description="Unique invoice identifier")
line_items: list[LineItem] = Field(description="All items billed on this invoice")
total_due: float = Field(description="Total amount due in USD")
structured_llm = llm.with_structured_output(Invoice)
raw_text = """
Invoice #INV-88214 from Cascade Office Supplies.
3 units of standing desks at $410.00 each.
12 units of monitor arms at $65.50 each.
Total due: $2016.00
"""
invoice = structured_llm.invoke(raw_text)
for item in invoice.line_items:
print(f"{item.quantity}x {item.description} @ ${item.unit_price}")
print(f"Total: ${invoice.total_due}")The model has to correctly populate a list of nested objects, each with its own typed fields — and it does, because the JSON Schema LangChain generates recursively describes the nested structure. This is the pattern you want for invoice parsing, resume parsing, meeting-notes-to-action-items, and virtually any "unstructured document to structured record" task.
A practical tip: keep nested models shallow where you can. Three levels of nesting is usually fine; five or six starts to strain smaller or cheaper models, especially ones running structured output through the JSON-mode fallback rather than native tool calling.
Enums and Literals for Constrained Choices
A common mistake is using a plain str field when you actually want the model to pick from a fixed set of options. If you leave it as str, you'll eventually get "high" when you expected "High" or "URGENT" when you expected "urgent." Constrain it explicitly.
from enum import Enum
from pydantic import BaseModel, Field
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class Category(str, Enum):
BILLING = "billing"
TECHNICAL = "technical"
ACCOUNT = "account"
FEATURE_REQUEST = "feature_request"
class SupportTicket(BaseModel):
summary: str = Field(description="One-sentence summary of the customer's issue")
category: Category = Field(description="Which team should handle this ticket")
priority: Priority = Field(description="How urgently this needs attention")
requires_escalation: bool = Field(description="Whether this needs a human manager")
structured_llm = llm.with_structured_output(SupportTicket)
ticket = structured_llm.invoke(
"My production API keys stopped working an hour before our biggest sales "
"event of the year and support hasn't responded in 40 minutes. This is costing "
"us thousands per minute."
)
print(ticket.category, ticket.priority, ticket.requires_escalation)Because Priority and Category are enums, the JSON Schema LangChain generates includes an explicit enum constraint with the exact allowed values. The model is no longer guessing at vocabulary — it's selecting from a closed set, which is a categorically more reliable operation for an LLM than free-text generation. This is the single highest-leverage change you can make if you're doing any kind of classification, routing, or triage with LLMs.
Handling Ambiguity with Optional Fields and Unions
Real inputs are messy. Sometimes the information you want just isn't in the text. A naive schema will pressure the model into hallucinating a value rather than admitting it doesn't know. Fix this by making absence a first-class, expected outcome.
from pydantic import BaseModel, Field
class OrderDetails(BaseModel):
order_id: str = Field(description="The order identifier")
shipping_address: str | None = Field(
default=None,
description="Shipping address if mentioned, otherwise null"
)
estimated_delivery: str | None = Field(
default=None,
description="Estimated delivery date if mentioned, otherwise null"
)
is_gift: bool = Field(
default=False,
description="Whether the customer indicated this is a gift"
)The | None union with an explicit default=None communicates two things at once: to Pydantic, that the field can validly be absent, and to the model (via the schema and description), that it's acceptable — expected, even — to return null rather than inventing an address that was never mentioned. This single pattern eliminates a large fraction of structured-output hallucination issues, because you've removed the pressure to always produce a value.
For classification tasks where the output could be one of several *entirely different* shapes, Python's Union (or the | operator) lets the model pick which schema applies:
from pydantic import BaseModel, Field
from typing import Union
class RefundRequest(BaseModel):
request_type: str = "refund"
order_id: str
reason: str
class TechnicalIssue(BaseModel):
request_type: str = "technical"
error_description: str
affected_feature: str
class GeneralInquiry(BaseModel):
request_type: str = "general"
question: str
structured_llm = llm.with_structured_output(
Union[RefundRequest, TechnicalIssue, GeneralInquiry]
)
result = structured_llm.invoke("The app crashes every time I try to export a PDF report.")
print(type(result).__name__, "->", result)This is a clean way to build routers: one LLM call classifies the input into one of N well-typed shapes, and the rest of your code can branch on isinstance(result, TechnicalIssue) instead of parsing free text to figure out intent.
Choosing the Right Method: function_calling vs json_mode vs json_schema
with_structured_output() accepts a method parameter that controls the underlying mechanism, and picking the right one matters for reliability and cost.
# Default on most providers: uses native tool/function calling
structured_llm = llm.with_structured_output(ContactInfo, method="function_calling")
# Forces raw JSON mode (provider must support it)
structured_llm = llm.with_structured_output(ContactInfo, method="json_mode")
# Uses provider-native structured output / strict JSON schema mode
structured_llm = llm.with_structured_output(ContactInfo, method="json_schema")- `function_calling` is the safest default across providers. It routes through the model's tool-calling machinery, which most providers have hardened specifically for reliability.
- `json_schema` is worth using explicitly on providers that support strict schema-constrained decoding (where the provider guarantees the output will match the schema at the token-sampling level, not just "probably will"). When available, this is the most reliable option because malformed output becomes structurally impossible rather than just unlikely.
- `json_mode` is a fallback for models that support "must be valid JSON" but don't have a schema-aware mode. You still get valid JSON, but the *shape* isn't enforced the way it is with the other two — Pydantic validation is doing more of the heavy lifting after the fact.
If you don't pass method at all, LangChain picks a sensible default per model, and for most day-to-day work that default is fine. But when you hit occasional validation failures in production, trying an explicit method swap is often the fastest fix.
Adding raw output access with include_raw
Sometimes you don't just want the parsed Pydantic object — you want visibility into what the model actually returned, for logging, debugging, or cases where you want to handle parsing failures yourself instead of letting an exception propagate.
structured_llm = llm.with_structured_output(ContactInfo, include_raw=True)
response = structured_llm.invoke(
"Reach out to Marcus at marcus@buildwright.dev about the contract renewal."
)
print(response["raw"]) # the original AIMessage from the model
print(response["parsed"]) # the validated ContactInfo instance, or None on failure
print(response["parsing_error"]) # the exception, if parsing failedWith include_raw=True, the return value changes shape: instead of getting the Pydantic instance directly, you get a dictionary with raw, parsed, and parsing_error keys. This is the pattern to reach for once you move past prototyping — it lets you log the raw model output for debugging, gracefully degrade when parsed is None, and inspect parsing_error to understand *why* validation failed rather than just catching a generic exception.
Building a Resilient Extraction Pipeline
In production, you rarely call with_structured_output() once and walk away. You build a small pipeline around it: a prompt template for consistent instructions, a retry strategy for the rare validation failure, and logging for observability. Here's a pattern that ties it together with LangChain Expression Language (LCEL).
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field, ValidationError
class MeetingActionItem(BaseModel):
task: str = Field(description="What needs to be done")
owner: str = Field(description="Who is responsible")
due_date: str | None = Field(default=None, description="Deadline if mentioned")
class MeetingSummary(BaseModel):
title: str = Field(description="A short title for the meeting")
key_decisions: list[str] = Field(description="Decisions that were made")
action_items: list[MeetingActionItem] = Field(description="Follow-up tasks")
prompt = ChatPromptTemplate.from_messages([
("system", "You extract structured meeting summaries from raw transcripts. "
"Be precise. Do not invent action items that weren't discussed."),
("human", "{transcript}")
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(MeetingSummary)
chain = prompt | structured_llm
def extract_with_retry(transcript: str, max_attempts: int = 2) -> MeetingSummary | None:
for attempt in range(1, max_attempts + 1):
try:
return chain.invoke({"transcript": transcript})
except ValidationError as e:
print(f"Attempt {attempt} failed validation: {e}")
return None
transcript = """
Product sync, Tuesday. We decided to delay the v2 launch by two weeks to fix
onboarding bugs. Priya will own the updated timeline doc by Friday.
Dev team agreed to cut the legacy CSV importer from this release.
"""
summary = extract_with_retry(transcript)
if summary:
print(summary.title)
for item in summary.action_items:
print(f"- {item.task} (owner: {item.owner}, due: {item.due_date})")Notice the chain composition: prompt | structured_llm. This is standard LCEL — the prompt template renders your input into messages, and the result is piped straight into the structured-output-bound model. The retry wrapper around chain.invoke() catches ValidationError specifically rather than a bare except Exception, so you're not accidentally swallowing unrelated bugs (network errors, auth failures) in your retry loop.
A practical note on prompting alongside with_structured_output(): system instructions still matter, even though the schema is enforced. The schema controls *shape*; the system prompt still controls *judgment* — telling the model not to fabricate action items, to be conservative about due dates it isn't sure of, and so on. Structured output guarantees you get a syntactically valid MeetingSummary. It does not guarantee the model didn't confidently make something up inside a perfectly valid field. That's a prompting problem, not a schema problem, and the two need to be solved together.
Streaming Structured Output
For long-running extraction tasks, or UIs that want to show partial results as they come in, LangChain supports streaming structured output — though with a caveat worth understanding.
structured_llm = llm.with_structured_output(MeetingSummary)
for chunk in structured_llm.stream({"transcript": transcript}):
print(chunk)Depending on the provider and method, streaming structured output typically yields progressively more complete partial objects rather than token-by-token text — you'll see the same MeetingSummary-shaped object appear multiple times with more fields populated (or list items appended) as generation continues. This is genuinely useful for a UI that wants to render a form as it fills in, but don't rely on early partial chunks for business logic — only the final chunk is guaranteed to have passed full Pydantic validation. Treat intermediate chunks as display-only.
Common Pitfalls and How to Avoid Them
A few mistakes come up repeatedly when engineers first adopt with_structured_output():
- Overly deep or overly wide schemas. A single Pydantic model with 40 fields, or nested five layers deep, pushes weaker models toward partial failures. Split large extraction tasks into multiple focused calls rather than one giant schema.
- Vague field descriptions.
Field(description="the date")gives the model nothing to work with.Field(description="Delivery date in YYYY-MM-DD format, or null if not mentioned")removes an entire category of formatting bugs. - Forgetting `temperature=0` for extraction tasks. Structured output constrains *shape*, not *content quality*. If you're extracting facts rather than generating creative text, keep temperature low so the model isn't improvising on the substance of the fields.
- Not handling the `None` case. With
include_raw=True, always checkparsed is not Nonebefore using it. It's tempting to assume the happy path in a demo and then get bitten in production when a single malformed response takes down a request. - Assuming every provider handles constraints identically. Not every provider's structured output mode supports every Pydantic feature (deeply nested unions, certain constraint types like
min_lengthat all levels). If you're switching providers, re-test your schemas rather than assuming parity.
Wrapping Up
with_structured_output() paired with Pydantic models turns LLM output from a string you have to interrogate into a typed object you can trust. You define the contract once — field names, types, descriptions, optionality, constrained choices via enums — and LangChain handles translating that into whatever mechanism the underlying provider uses to honor it, whether that's native tool calling, strict JSON schema mode, or JSON mode with post-hoc validation.
The patterns in this article — nested models for real-world documents, enums for classification, optional fields for honest uncertainty, Union types for routing, include_raw for observability, and retry wrappers around ValidationError — are the building blocks of nearly every production LLM extraction pipeline you'll build. Master these and you'll spend a lot less time writing regex to rescue malformed JSON, and a lot more time actually shipping features.
If you want to go deeper — chaining structured extraction with agents, combining it with tool use, handling multi-provider fallback, and building full RAG and agent pipelines around these patterns — that's exactly what we cover hands-on in our LangChain Tutorial 2026 course, where you'll build and ship real LangChain applications from scratch.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.