LangGraph with_structured_output Inside Graph Nodes — Fix Guide (2026)
Agents · Structured Output Severity: Medium

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.

TL;DRwith_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

ValidationError on graph invoke
ValidationError on graph invoke
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.
Streaming produces no chunks
Streaming produces no chunks
# 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.
Structured output silently disabled with tools
Structured output silently disabled with tools
# 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 worksBest for
"function_calling"Uses tool-calling under the hood; schema becomes a synthetic toolDefault; 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 JSONLegacy; prefer json_schema
include_raw=TrueReturns dict with raw AIMessage + parsed modelDebugging; 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_output buffers all chunks and validates once at the end. Downstream code that expects astream_events chunks 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 with method="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 as B. 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 raises TypeError: coroutine has no attribute...
  • 7%
    Model doesn't support structured output at all. Older or non-chat models raise NotImplementedError or fail silently.

How to fix it

Fix #1

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

forgiving_schema.pypython
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"]]}
Note: Every field in your schema needs a 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".
Fix #2

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.

tool_use_then_structure.pypython
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"])
Note: This two-node pattern also gives you an explicit "structuring" step that's easy to iterate on independently. Change the Report schema without touching the tool-using logic; change the tools without touching the report shape.
Fix #3

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

json_schema_method.pypython
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
Note: The 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 are Optional, and validators coerce common variants.
  • Never mix bind_tools and with_structured_output on the same call — split into separate nodes.
  • Use include_raw=True in 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_output to 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

Why doesn't with_structured_output stream partial JSON?

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.

Can I use with_structured_output with a plain TypedDict instead of Pydantic?

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.

What if the model refuses to return structured output?

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.

Does with_structured_output count as a tool call for billing?

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.

Can I have a graph where every node uses with_structured_output?

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.

Related errors