LangGraph with_structured_output Inside Graph Nodes
with_structured_output works cleanly in isolation but starts to fail in weird ways once it's inside a LangGraph node — validation errors on partial JSON, streaming that goes silent, schema fields that quietly stop being sent. Here's what changes when structured output meets the graph, and the three patterns that keep it stable.
By Sana K. · Last updated Aug 14, 2026 · LangGraph · Page #132
with_structured_output inside a graph node fails when (1) the state schema and the output schema drift, (2) you stream the graph and expect with_structured_output to stream too (it doesn't), or (3) you combine it with other tools on the same call. Fix by keeping the output schema versioned alongside state, using method="json_schema" for provider stability, and using Command to route to a separate node when you need both tools and structured final output.Real error messages you'll see
pydantic_core._pydantic_core.ValidationError: 2 validation errors for AnswerSchema
confidence
Field required [type=missing]
citations
List should have at least 1 item after validation [type=too_short]
# The model returned partial JSON; either the schema is too strict or the prompt didn't force all fields.
# app.astream_events() yields only start/end events for the node — no partial content.
# with_structured_output disables token streaming: the whole JSON must arrive before Pydantic validates.
# Fix: use with_structured_output(include_raw=True) and stream the raw text separately, or split into two nodes.
# Model called a tool instead of returning structured output.
# Combining tools=[...] and with_structured_output on the same model call is ambiguous —
# most providers pick tool_calling and ignore the schema, or vice versa.
# Fix: use separate nodes for tool use and structured final answer.
with_structured_output methods
| method= | How it works | Best for |
|---|---|---|
"function_calling" | Uses tool-calling under the hood; schema becomes a synthetic tool | Default; broad model support |
"json_schema" | Uses provider's native JSON-schema mode (Anthropic, OpenAI, Gemini) | Cleanest for pure structured output — no fake tool |
"json_mode" | Older OpenAI JSON mode; the model must be told to output JSON | Legacy; prefer json_schema |
include_raw=True | Returns dict with raw AIMessage + parsed model | Debugging; when you also need the raw content |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 23%Schema too strict. Every field required, minimum list lengths, regex patterns. Model produces plausible output that fails validation.
- 18%Streaming assumption.
with_structured_outputbuffers all chunks and validates once at the end. Downstream code that expectsastream_eventschunks sees nothing meaningful. - 15%Combining with tools on the same call. Structured output uses the tool-calling channel; adding tools creates ambiguity. Model picks one and drops the other.
- 12%Provider quirks —
method="function_calling"as default. Some models handle nested schemas better withmethod="json_schema"; sticking with the default causes silent field drops. - 10%Schema drift. Pydantic model changed after the graph was compiled — old fields still requested, new fields missing. Especially painful with hot reload.
- 8%State expects one type; node returns another. Node runs
llm.with_structured_output(A)but state field is typed asB. Silent type mismatch — downstream nodes crash later. - 7%Async model with sync
invoke. Structured-output wrapper unwraps to a Pydantic instance in one variant, awaits in the other. Mixing raisesTypeError: coroutine has no attribute... - 7%Model doesn't support structured output at all. Older or non-chat models raise
NotImplementedErroror fail silently.
How to fix it
Design the schema for the model, not the human
Fixes ValidationError on graph invoke.
A schema that's clean Python (every field required, strict types) often fails when the model produces plausible-but-imperfect JSON. Make the schema forgiving: Optional where the field genuinely might be absent, sensible defaults, and permissive types on numeric fields (accept both int and float, then normalize in a validator).
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field, field_validator
# ❌ TOO STRICT — model often fails at least one field
class StrictAnswer(BaseModel):
answer: str = Field(min_length=10)
confidence: float = Field(ge=0.0, le=1.0)
citations: list[str] = Field(min_length=1, max_length=5)
reasoning: str = Field(min_length=50)
tags: list[Literal["factual", "opinion", "computed"]] = Field(min_length=1)
# ✅ FORGIVING — most fields optional or with defaults; model succeeds
class Answer(BaseModel):
answer: str = Field(description="The concise final answer to the user")
confidence: float = Field(
default=0.5,
ge=0.0, le=1.0,
description="Confidence in the answer, 0-1",
)
citations: list[str] = Field(
default_factory=list,
description="Source URLs supporting the answer, if any",
)
reasoning: Optional[str] = Field(
default=None,
description="Brief reasoning trace (optional)",
)
@field_validator("confidence", mode="before")
@classmethod
def coerce_conf(cls, v):
# Model sometimes returns 87 instead of 0.87 — coerce
if isinstance(v, (int, float)) and v > 1.0:
return v / 100.0
return v
# ✅ Use it in a graph node
from langgraph.graph import StateGraph, START, END, MessagesState
def answer_node(state):
structured_llm = model.with_structured_output(Answer, method="json_schema")
result: Answer = structured_llm.invoke(state["messages"])
# Put the structured value in state under an explicit key
return {"answer": result.model_dump()}
# ✅ State knows about the field
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
answer: dict | None # populated by answer_node
# ✅ include_raw=True — get both the parsed result AND the original AIMessage
def answer_node_with_raw(state):
wrapper = model.with_structured_output(Answer, method="json_schema", include_raw=True)
result = wrapper.invoke(state["messages"])
# result is a dict: {"raw": AIMessage, "parsed": Answer or None, "parsing_error": Exception or None}
if result["parsed"] is None:
return {"answer": None, "messages": [result["raw"]]}
return {"answer": result["parsed"].model_dump(), "messages": [result["raw"]]}
description. That description is what the model sees — think of it as prompt engineering, not documentation. "Confidence 0-1" is clearer to a model than "The confidence score".Split tool use and structured output into separate nodes
Fixes "structured output silently disabled with tools".
Both structured output and tool calling use the model's tool-use channel. Combining them on one call is ambiguous — most providers pick one. The clean pattern is to give the agent tool access, let it loop until no more tools are needed, then run a final "structuring" node with with_structured_output and no tools bound.
from typing import Annotated, TypedDict, Literal
from pydantic import BaseModel, Field
from langchain_core.messages import AIMessage, SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
class Report(BaseModel):
summary: str = Field(description="1-3 sentence summary")
key_findings: list[str] = Field(default_factory=list, description="Bullet-point findings")
sources_used: list[str] = Field(default_factory=list, description="Tool call names used")
class State(TypedDict):
messages: Annotated[list, add_messages]
report: dict | None
# Node 1 — model with tools, loops until no more tool calls
def researcher(state):
llm_with_tools = model.bind_tools([search, fetch_url])
reply = llm_with_tools.invoke(state["messages"])
return {"messages": [reply]}
def route_researcher(state) -> Literal["tools", "structurer"]:
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "tools"
return "structurer"
# Node 2 — final structuring, NO tools, just structured output
def structurer(state):
# A separate model call — no bind_tools, just with_structured_output
structured = model.with_structured_output(Report, method="json_schema")
# Feed it a fresh prompt that asks for the report shape
prompt = [
SystemMessage(content=(
"Read the conversation above and produce a structured report. "
"Do NOT call any tools. Just return the Report schema."
)),
*state["messages"],
]
report: Report = structured.invoke(prompt)
return {"report": report.model_dump()}
graph = StateGraph(State)
graph.add_node("researcher", researcher)
graph.add_node("tools", ToolNode([search, fetch_url]))
graph.add_node("structurer", structurer)
graph.add_edge(START, "researcher")
graph.add_conditional_edges("researcher", route_researcher)
graph.add_edge("tools", "researcher") # loop back with tool results
graph.add_edge("structurer", END) # terminal
app = graph.compile()
result = app.invoke({
"messages": [("user", "Research LangGraph's checkpoint model and summarize.")],
"report": None,
})
print(result["report"])
Report schema without touching the tool-using logic; change the tools without touching the report shape.Prefer method="json_schema" for cleaner behavior on modern models
Fixes provider quirks with nested schemas and field drops.
method="function_calling" is the default because it works on the widest set of models, but modern flagships (Claude 4.x, GPT-4.1, Gemini 2.5) have native JSON schema support that's stricter and often more reliable for nested schemas. When your schema has nested objects, arrays of objects, or discriminated unions, prefer method="json_schema".
from typing import Literal, Union
from pydantic import BaseModel, Field, Discriminator, Tag
# ✅ Nested schema — json_schema handles this cleaner than function_calling
class Address(BaseModel):
street: str
city: str
zip: str
class Contact(BaseModel):
name: str
email: str
address: Address # nested
phones: list[str] = Field(default_factory=list, max_length=3)
# ✅ Discriminated union — tool_calling flattens; json_schema respects the discriminator
class SearchAction(BaseModel):
kind: Literal["search"] = "search"
query: str
class ClickAction(BaseModel):
kind: Literal["click"] = "click"
selector: str
class TypeAction(BaseModel):
kind: Literal["type"] = "type"
selector: str
text: str
class BrowserPlan(BaseModel):
goal: str
actions: list[SearchAction | ClickAction | TypeAction] = Field(min_length=1)
# ✅ Use json_schema on Claude / GPT / Gemini
structured = model.with_structured_output(
BrowserPlan,
method="json_schema", # <-- explicit; cleaner for nested/union
strict=True, # OpenAI: enforces schema; Claude ignores
)
def planner(state):
plan: BrowserPlan = structured.invoke(state["messages"])
return {"plan": plan.model_dump()}
# ✅ include_raw=True is a good default in production for auditability
production_structured = model.with_structured_output(
Contact,
method="json_schema",
include_raw=True,
)
def robust_extract(state):
result = production_structured.invoke(state["messages"])
if result["parsed"] is None:
# Log the raw output and the parsing error, don't crash
import logging
logging.warning(
"structured output failed: err=%s raw=%r",
result["parsing_error"],
result["raw"].content[:500],
)
return {"contact": None}
return {"contact": result["parsed"].model_dump()}
# ✅ Fallback chain — try json_schema, fall back to function_calling
def robust_structure(model, schema):
primary = model.with_structured_output(schema, method="json_schema", include_raw=True)
fallback = model.with_structured_output(schema, method="function_calling", include_raw=True)
def _extract(messages):
r = primary.invoke(messages)
if r["parsed"] is not None:
return r["parsed"]
r = fallback.invoke(messages)
return r["parsed"] # may still be None
return _extract
strict=True flag is OpenAI-specific and enforces the schema at the model level (their strict-mode tool calling). Anthropic and Gemini ignore it silently. Setting it is safe on all providers; you just don't get the extra enforcement outside OpenAI.Prevention checklist
- Design schemas for the model: every field has a
description, non-critical fields areOptional, and validators coerce common variants. - Never mix
bind_toolsandwith_structured_outputon the same call — split into separate nodes. - Use
include_raw=Truein production so you can log the raw output when parsing fails. - Prefer
method="json_schema"on modern flagship models — better nested-schema and union handling. - When streaming a graph, don't rely on
with_structured_outputto emit partial chunks — it buffers. - Version your schemas alongside the graph — schema drift causes silent field drops.
- Add field-level validators that gracefully coerce common model mistakes (percent vs fraction, string vs number).
Frequently asked questions
Because Pydantic validates only after the JSON is complete. LangChain's wrapper buffers the full JSON, parses it, validates it, then returns the model instance. If you need progressive UI updates, stream the raw text yourself (use include_raw=True and stream the raw AIMessage) and parse at the end — or use the provider's streaming JSON API directly and reconcile partial fields yourself.
Yes for the schema definition, but you lose validation. The wrapper accepts TypedDict and returns a dict conforming to the shape, but nothing enforces the types at runtime. If the model returns a string where you expected an int, you'll only find out when downstream code crashes. Prefer Pydantic in production — the small overhead pays for itself in fewer late-stage bugs.
Two common reasons: the model was primed with tools that took precedence, or the schema conflicts with the model's safety guidelines (e.g. a field asking for personal information). Fix the first by splitting nodes (Fix #2). Fix the second by softening field descriptions or making sensitive fields optional. In extreme cases, add a system_message that explicitly asks for the structured output.
Yes on providers that implement it via tool calling (OpenAI's default, Anthropic's tool_use). The billing footprint is roughly the same as a normal call plus the schema tokens. On providers with native JSON schema mode (Anthropic's output_format, Gemini's response_schema), there's no synthetic tool and the token overhead is smaller — the schema is sent once in the request, not as a tool definition.
Yes and it's a great pattern for pipelines that pass structured data between nodes. Each node wraps its model call with a different schema, the state carries the parsed dicts, and downstream nodes read them as typed inputs. Just be aware that every wrapped call costs at least the schema-token overhead, so consolidate closely-related outputs into one call when you can.