Graph silently converts output to Dict even though it should be Pydantic class #1096

Closed
opened 2026-02-20 17:43:03 -05:00 by yindo · 8 comments
Owner

Originally created by @strentom on GitHub (Dec 17, 2025).

Checked other resources

  • This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

from typing import TypedDict

from langgraph.graph import StateGraph
from pydantic import BaseModel


class OutputModel(BaseModel):
    message: str

class InputState(TypedDict):
    input: str

def process(state: InputState) -> OutputModel:
    return OutputModel(message=f"Processed: {state['input']}")

graph = StateGraph(InputState, output_schema=OutputModel)
graph.add_node(process)
graph.add_edge("__start__", "process")
graph.add_edge("process", "__end__")
compiled = graph.compile()

result = compiled.invoke({"input": "test"})

result.message  # throws error because 'result' is of type 'dict

Error Message and Stack Trace (if applicable)


Description

I have a graph with structured output of Pydantic class. (Orthogonal note: I don't use TypedDict because the class has Enums and these are incorrectly deserialized in structured outputs, but it works for pydantic.) When a Pydantic instance is returned from last node in graph, it gets converted to a dictionary. This is silent and undesired.

System Info

System Information
------------------
> OS:  Darwin
> OS Version:  Darwin Kernel Version 24.6.0: Wed Oct 15 21:12:08 PDT 2025; root:xnu-11417.140.69.703.14~1/RELEASE_ARM64_T6020
> Python Version:  3.12.11 (main, Oct  9 2025, 10:09:01) [Clang 16.0.0 (clang-1600.0.26.6)]

Package Information
-------------------
> langchain_core: 1.2.1
> langchain: 1.0.2
> langchain_community: 0.4.1
> langsmith: 0.4.33
> langchain_classic: 1.0.0
> langchain_google_genai: 4.0.0
> langchain_google_vertexai: 3.2.0
> langchain_model_profiles: 0.0.5
> langchain_openai: 1.1.3
> langchain_text_splitters: 0.3.11
> langgraph_api: 0.4.38
> langgraph_cli: 0.4.3
> langgraph_runtime_inmem: 0.14.1
> langgraph_sdk: 0.2.9
> langgraph_supervisor: 0.0.28

Optional packages not installed
-------------------------------
> langserve

Other Dependencies
------------------
> aiohttp: 3.13.0
> anthropic: 0.72.0
> blockbuster: 1.5.25
> bottleneck: 1.6.0
> click: 8.3.0
> cloudpickle: 3.1.1
> cryptography: 44.0.3
> dataclasses-json: 0.6.7
> filetype: 1.2.0
> google-cloud-aiplatform: 1.130.0
> google-cloud-storage: 3.4.1
> google-genai: 1.55.0
> grpcio: 1.75.1
> grpcio-tools: 1.75.1
> httpx: 0.28.1
> httpx-sse: 0.4.2
> jsonpatch: 1.33
> jsonschema-rs: 0.29.1
> langgraph: 1.0.1
> langgraph-checkpoint: 2.1.2
> numexpr: 2.14.1
> numpy: 2.3.3
> openai: 2.3.0
> opentelemetry-api: 1.37.0
> opentelemetry-exporter-otlp-proto-http: 1.37.0
> opentelemetry-sdk: 1.37.0
> orjson: 3.11.3
> packaging: 25.0
> protobuf: 6.32.1
> pyarrow: 22.0.0
> pydantic: 2.12.0
> pydantic-settings: 2.12.0
> pyjwt: 2.10.1
> pytest: 8.4.2
> python-dotenv: 1.1.1
> PyYAML: 6.0.3
> pyyaml: 6.0.3
> requests: 2.32.5
> requests-toolbelt: 1.0.0
> rich: 14.2.0
> SQLAlchemy: 2.0.43
> sqlalchemy: 2.0.43
> sse-starlette: 2.1.3
> starlette: 0.47.3
> structlog: 25.4.0
> tenacity: 9.1.2
> tiktoken: 0.12.0
> truststore: 0.10.4
> typing-extensions: 4.15.0
> uuid-utils: 0.12.0
> uvicorn: 0.37.0
> validators: 0.35.0
> watchfiles: 1.1.0
> zstandard: 0.25.0
Originally created by @strentom on GitHub (Dec 17, 2025). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/). - [x] I added a clear and detailed title that summarizes the issue. - [x] I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). - [x] I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue. ### Example Code ```python from typing import TypedDict from langgraph.graph import StateGraph from pydantic import BaseModel class OutputModel(BaseModel): message: str class InputState(TypedDict): input: str def process(state: InputState) -> OutputModel: return OutputModel(message=f"Processed: {state['input']}") graph = StateGraph(InputState, output_schema=OutputModel) graph.add_node(process) graph.add_edge("__start__", "process") graph.add_edge("process", "__end__") compiled = graph.compile() result = compiled.invoke({"input": "test"}) result.message # throws error because 'result' is of type 'dict ``` ### Error Message and Stack Trace (if applicable) ```shell ``` ### Description I have a graph with structured output of Pydantic class. *(Orthogonal note: I don't use TypedDict because the class has Enums and these are incorrectly deserialized in structured outputs, but it works for pydantic.)* When a Pydantic instance is returned from last node in graph, it gets converted to a dictionary. This is silent and undesired. ### System Info ```shell System Information ------------------ > OS: Darwin > OS Version: Darwin Kernel Version 24.6.0: Wed Oct 15 21:12:08 PDT 2025; root:xnu-11417.140.69.703.14~1/RELEASE_ARM64_T6020 > Python Version: 3.12.11 (main, Oct 9 2025, 10:09:01) [Clang 16.0.0 (clang-1600.0.26.6)] Package Information ------------------- > langchain_core: 1.2.1 > langchain: 1.0.2 > langchain_community: 0.4.1 > langsmith: 0.4.33 > langchain_classic: 1.0.0 > langchain_google_genai: 4.0.0 > langchain_google_vertexai: 3.2.0 > langchain_model_profiles: 0.0.5 > langchain_openai: 1.1.3 > langchain_text_splitters: 0.3.11 > langgraph_api: 0.4.38 > langgraph_cli: 0.4.3 > langgraph_runtime_inmem: 0.14.1 > langgraph_sdk: 0.2.9 > langgraph_supervisor: 0.0.28 Optional packages not installed ------------------------------- > langserve Other Dependencies ------------------ > aiohttp: 3.13.0 > anthropic: 0.72.0 > blockbuster: 1.5.25 > bottleneck: 1.6.0 > click: 8.3.0 > cloudpickle: 3.1.1 > cryptography: 44.0.3 > dataclasses-json: 0.6.7 > filetype: 1.2.0 > google-cloud-aiplatform: 1.130.0 > google-cloud-storage: 3.4.1 > google-genai: 1.55.0 > grpcio: 1.75.1 > grpcio-tools: 1.75.1 > httpx: 0.28.1 > httpx-sse: 0.4.2 > jsonpatch: 1.33 > jsonschema-rs: 0.29.1 > langgraph: 1.0.1 > langgraph-checkpoint: 2.1.2 > numexpr: 2.14.1 > numpy: 2.3.3 > openai: 2.3.0 > opentelemetry-api: 1.37.0 > opentelemetry-exporter-otlp-proto-http: 1.37.0 > opentelemetry-sdk: 1.37.0 > orjson: 3.11.3 > packaging: 25.0 > protobuf: 6.32.1 > pyarrow: 22.0.0 > pydantic: 2.12.0 > pydantic-settings: 2.12.0 > pyjwt: 2.10.1 > pytest: 8.4.2 > python-dotenv: 1.1.1 > PyYAML: 6.0.3 > pyyaml: 6.0.3 > requests: 2.32.5 > requests-toolbelt: 1.0.0 > rich: 14.2.0 > SQLAlchemy: 2.0.43 > sqlalchemy: 2.0.43 > sse-starlette: 2.1.3 > starlette: 0.47.3 > structlog: 25.4.0 > tenacity: 9.1.2 > tiktoken: 0.12.0 > truststore: 0.10.4 > typing-extensions: 4.15.0 > uuid-utils: 0.12.0 > uvicorn: 0.37.0 > validators: 0.35.0 > watchfiles: 1.1.0 > zstandard: 0.25.0 ```
yindo added the bugpending labels 2026-02-20 17:43:03 -05:00
yindo closed this issue 2026-02-20 17:43:03 -05:00
Author
Owner

@thoffmann-artidis commented on GitHub (Dec 18, 2025):

In general, langchain/langgraphs typing strategy is buggy and convoluted. See this example:

import operator
from typing import Annotated

from langgraph.graph import StateGraph
from langgraph.prebuilt import ToolNode
from langchain_core.messages import BaseMessage, ToolMessage, AIMessage
from langchain.tools import tool
from langgraph.types import Command
from pydantic import BaseModel


class State(BaseModel):

    messages: Annotated[list[BaseMessage], operator.add]
    tool_output: str | None = None


@tool
def a_tool() -> Command:
    """add tool_output to state"""
    return Command(update={'tool_output': 'a random tool output',
                           'messages': [ToolMessage(
                               content='Tool executed',
                               tool_call_id='manual')]})


workflow = StateGraph(State)
workflow.add_node('tool', ToolNode([a_tool]))
workflow.set_entry_point('tool')
workflow.set_finish_point('tool')
graph = workflow.compile()

out = graph.invoke(State(messages=[AIMessage(
    content='A tool call',
    tool_calls=[{'id': 'manual', 'args': {},'name': 'a_tool'}])]))

print(type(out))     # '<class 'dict'>'

First, the output of the graph is a dict, although logically, it should be an instance of State.

Secondly, if you aim to write to the state from a tool call, you need to use Command(update=...). update is typed as Any. Hence, in your tool, you need to have implicit knowledge of the state variable. That's bad software design. Is there a better way to deal with this?

@thoffmann-artidis commented on GitHub (Dec 18, 2025): In general, langchain/langgraphs typing strategy is buggy and convoluted. See this example: ``` import operator from typing import Annotated from langgraph.graph import StateGraph from langgraph.prebuilt import ToolNode from langchain_core.messages import BaseMessage, ToolMessage, AIMessage from langchain.tools import tool from langgraph.types import Command from pydantic import BaseModel class State(BaseModel): messages: Annotated[list[BaseMessage], operator.add] tool_output: str | None = None @tool def a_tool() -> Command: """add tool_output to state""" return Command(update={'tool_output': 'a random tool output', 'messages': [ToolMessage( content='Tool executed', tool_call_id='manual')]}) workflow = StateGraph(State) workflow.add_node('tool', ToolNode([a_tool])) workflow.set_entry_point('tool') workflow.set_finish_point('tool') graph = workflow.compile() out = graph.invoke(State(messages=[AIMessage( content='A tool call', tool_calls=[{'id': 'manual', 'args': {},'name': 'a_tool'}])])) print(type(out)) # '<class 'dict'>' ``` First, the output of the graph is a `dict`, although logically, it should be an instance of `State`. Secondly, if you aim to write to the state from a tool call, you need to use `Command(update=...)`. `update` is typed as `Any`. Hence, in your tool, you need to have implicit knowledge of the state variable. That's bad software design. Is there a better way to deal with this?
Author
Owner

@Rakshit-gen commented on GitHub (Dec 19, 2025):

fixed this issue check #6608

@Rakshit-gen commented on GitHub (Dec 19, 2025): fixed this issue check #6608
Author
Owner

@Zi-Ling commented on GitHub (Dec 23, 2025):

This silent conversion is pretty dangerous.

We’ve seen similar cases where downstream logic
assumes a typed model but actually receives a dict,
and the corruption only shows up several steps later.

In your view, should this be treated as a hard failure
instead of silently coercing the output?

@Zi-Ling commented on GitHub (Dec 23, 2025): This silent conversion is pretty dangerous. We’ve seen similar cases where downstream logic assumes a typed model but actually receives a dict, and the corruption only shows up several steps later. In your view, should this be treated as a hard failure instead of silently coercing the output?
Author
Owner

@Rakshit-gen commented on GitHub (Dec 23, 2025):

@Zi-Ling You're right. Silent coercion is dangerous - I'll remove the try/except and let validation errors propagate. If the user specified a Pydantic output_schema, validation failure means the graph produced invalid state, which IS an error that should fail fast rather than corrupt downstream. I can push a new fix

@Rakshit-gen commented on GitHub (Dec 23, 2025): @Zi-Ling You're right. Silent coercion is dangerous - I'll remove the try/except and let validation errors propagate. If the user specified a Pydantic output_schema, validation failure means the graph produced invalid state, which IS an error that should fail fast rather than corrupt downstream. I can push a new fix
Author
Owner

@Rakshit-gen commented on GitHub (Dec 23, 2025):

pushed a fix @Zi-Ling

@Rakshit-gen commented on GitHub (Dec 23, 2025): pushed a fix @Zi-Ling
Author
Owner

@Zi-Ling commented on GitHub (Dec 23, 2025):

Awesome, thanks for the quick fix 🙏
Fail-fast on validation errors feels like the right boundary here.
Do you know which release/version this will land in?

@Zi-Ling commented on GitHub (Dec 23, 2025): Awesome, thanks for the quick fix 🙏 Fail-fast on validation errors feels like the right boundary here. Do you know which release/version this will land in?
Author
Owner

@Rakshit-gen commented on GitHub (Dec 23, 2025):

@Zi-Ling Thanks! The fix is ready for review in this PR. As for the release version, that's up to the LangGraph maintainers to decide once the PR is merged. You can track the releases page or subscribe to the PR for updates

@Rakshit-gen commented on GitHub (Dec 23, 2025): @Zi-Ling Thanks! The fix is ready for review in this PR. As for the release version, that's up to the LangGraph maintainers to decide once the PR is merged. You can track the [releases page](https://github.com/langchain-ai/langgraph/releases) or subscribe to the PR for updates
Author
Owner

@sydney-runkle commented on GitHub (Jan 9, 2026):

Closing as a dupe of https://github.com/langchain-ai/langgraph/issues/5024, this is breaking

@sydney-runkle commented on GitHub (Jan 9, 2026): Closing as a dupe of https://github.com/langchain-ai/langgraph/issues/5024, this is breaking
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#1096