Inconsistent reducer behavior for Optional, nullable and regular data type #585

Closed
opened 2026-02-20 17:40:49 -05:00 by yindo · 5 comments
Owner

Originally created by @xiangyuwang-mai on GitHub (Apr 16, 2025).

Checked other resources

  • This is a bug, not a usage question. For questions, please use GitHub Discussions.
  • 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 Annotated, Optional

from pydantic import BaseModel
from langgraph.graph import StateGraph, START

def reducer(current, update):
    return update(current)

add_one = lambda x: x + 1

class State1(BaseModel):
    foo: Annotated[int, reducer] = 1
    boo: Annotated[int, reducer] = 1

class State2(BaseModel):
    foo: Annotated[Optional[int], reducer] = 1
    boo: Annotated[int, reducer] = 1

class State3(BaseModel):
    foo: Annotated[int | None, reducer] = 1
    boo: Annotated[int, reducer] = 1

def my_node(state):
    return {
        "foo": add_one,
        "boo": add_one,
    }

graph = (
    StateGraph(State1)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

graph = (
    StateGraph(State1)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = {}
print(f"The state 1 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")

graph = (
    StateGraph(State2)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = {}
print(f"The state 2 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")

graph = (
    StateGraph(State3)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = {}
print(f"The state 3 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")

Error Message and Stack Trace (if applicable)


Description

LangGraph ver 0.3.30

Observed inconsistent reducer behavior on types with different annotations. See the example code above. User defined a custom reducer that takes in a lambda to update the current value of the state.

description behavior
State1: all fields are normal. pydantic default value ignored. Reducer applied
State2: foo is optional Reducer not applied, raw lambda passed back
State3: foo is nullable Reducer not applied, raw lambda passed back

Additional observation

If adding a no-op node after my_node, you will observe different behavior between State2 and State3:

def no_op(state):
    return {}

graph = (
    StateGraph(State3)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .add_node(no_op)
    .add_edge("my_node", "no_op")
    .compile()
)
description behavior
State2: foo is optional Reducer not applied, raw lambda passed back
State3: foo is nullable Get error. Pydantic validation error

If value is set in input_state for example

input_state = {
    "foo": 1
}

Then you will also observe different behaviors for all three types.

description behavior
State1: all fields are normal Reducer called on int. Error
State2: foo is optional works as expected, default value used
State3: foo is nullable works as expected, default value used

System Info

OS: Darwin
OS Version: Darwin Kernel Version 24.1.0: Thu Oct 10 21:02:26 PDT 2024; root:xnu-11215.41.3~2/RELEASE_ARM64_T8122
Python Version: 3.11.11 (main, Dec 3 2024, 17:20:40) [Clang 16.0.0 (clang-1600.0.26.4)]

Package Information

langchain_core: 0.3.44
langsmith: 0.3.13
langgraph_sdk: 0.1.55

Optional packages not installed

langserve

Other Dependencies

httpx: 0.28.1
jsonpatch<2.0,>=1.33: Installed. No version info available.
langsmith-pyo3: Installed. No version info available.
langsmith<0.4,>=0.1.125: Installed. No version info available.
orjson: 3.10.15
packaging: 24.2
packaging<25,>=23.2: Installed. No version info available.
pydantic: 2.10.6
pydantic<3.0.0,>=2.5.2;: Installed. No version info available.
pydantic<3.0.0,>=2.7.4;: Installed. No version info available.
pytest: Installed. No version info available.
PyYAML>=5.3: Installed. No version info available.
requests: 2.32.3
requests-toolbelt: 1.0.0
rich: Installed. No version info available.
tenacity!=8.4.0,<10.0.0,>=8.1.0: Installed. No version info available.
typing-extensions>=4.7: Installed. No version info available.
zstandard: 0.23.0

Originally created by @xiangyuwang-mai on GitHub (Apr 16, 2025). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use GitHub Discussions. - [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 Annotated, Optional from pydantic import BaseModel from langgraph.graph import StateGraph, START def reducer(current, update): return update(current) add_one = lambda x: x + 1 class State1(BaseModel): foo: Annotated[int, reducer] = 1 boo: Annotated[int, reducer] = 1 class State2(BaseModel): foo: Annotated[Optional[int], reducer] = 1 boo: Annotated[int, reducer] = 1 class State3(BaseModel): foo: Annotated[int | None, reducer] = 1 boo: Annotated[int, reducer] = 1 def my_node(state): return { "foo": add_one, "boo": add_one, } graph = ( StateGraph(State1) .add_node(my_node) .add_edge(START, "my_node") .compile() ) graph = ( StateGraph(State1) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = {} print(f"The state 1 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") graph = ( StateGraph(State2) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = {} print(f"The state 2 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") graph = ( StateGraph(State3) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = {} print(f"The state 3 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") ``` ### Error Message and Stack Trace (if applicable) ```shell ``` ### Description LangGraph ver 0.3.30 Observed inconsistent reducer behavior on types with different annotations. See the example code above. User defined a custom reducer that takes in a lambda to update the current value of the state. | description | behavior | |--|--| |`State1`: all fields are normal. | pydantic default value ignored. Reducer applied | | `State2`: `foo` is optional | Reducer not applied, raw lambda passed back | | `State3`: `foo` is nullable | Reducer not applied, raw lambda passed back | Additional observation -- If adding a `no-op` node after `my_node`, you will observe different behavior between `State2` and `State3`: ```python def no_op(state): return {} graph = ( StateGraph(State3) .add_node(my_node) .add_edge(START, "my_node") .add_node(no_op) .add_edge("my_node", "no_op") .compile() ) ``` | description | behavior | |--|--| | `State2`: `foo` is optional | Reducer not applied, raw lambda passed back | | `State3`: `foo` is nullable | Get error. Pydantic validation error | If value is set in `input_state` for example ```python input_state = { "foo": 1 } ``` Then you will also observe different behaviors for all three types. | description | behavior | |--|--| |`State1`: all fields are normal | Reducer called on int. Error | | `State2`: `foo` is optional | works as expected, default value used | | `State3`: `foo` is nullable | works as expected, default value used | ### System Info > OS: Darwin > OS Version: Darwin Kernel Version 24.1.0: Thu Oct 10 21:02:26 PDT 2024; root:xnu-11215.41.3~2/RELEASE_ARM64_T8122 > Python Version: 3.11.11 (main, Dec 3 2024, 17:20:40) [Clang 16.0.0 (clang-1600.0.26.4)] Package Information ------------------- > langchain_core: 0.3.44 > langsmith: 0.3.13 > langgraph_sdk: 0.1.55 Optional packages not installed ------------------------------- > langserve Other Dependencies ------------------ > httpx: 0.28.1 > jsonpatch<2.0,>=1.33: Installed. No version info available. > langsmith-pyo3: Installed. No version info available. > langsmith<0.4,>=0.1.125: Installed. No version info available. > orjson: 3.10.15 > packaging: 24.2 > packaging<25,>=23.2: Installed. No version info available. > pydantic: 2.10.6 > pydantic<3.0.0,>=2.5.2;: Installed. No version info available. > pydantic<3.0.0,>=2.7.4;: Installed. No version info available. > pytest: Installed. No version info available. > PyYAML>=5.3: Installed. No version info available. > requests: 2.32.3 > requests-toolbelt: 1.0.0 > rich: Installed. No version info available. > tenacity!=8.4.0,<10.0.0,>=8.1.0: Installed. No version info available. > typing-extensions>=4.7: Installed. No version info available. > zstandard: 0.23.0
yindo closed this issue 2026-02-20 17:40:49 -05:00
Author
Owner

@vbarda commented on GitHub (Apr 16, 2025):

this is not how reducer functions are intended to be used (and i don't believe we recommend that in any of our documentation). the state update from the node is supposed to be an actual value to be used in a reducer, not a function.

note that for pydantic defaults to be respected, you would currently need to invoke the graph with State1() etc., not with an empty dict

these 3 variants give the same outputs (after changing to the proper reducer)

from typing import Annotated, Optional

from pydantic import BaseModel, Field
from langgraph.graph import StateGraph, START

def reducer(current, update):
    return current + update

class State2(BaseModel):
    foo: Annotated[Optional[int], reducer] = 1
    boo: Annotated[int, reducer] = 1

class State3(BaseModel):
    foo: Annotated[int | None, reducer] = 1
    boo: Annotated[int, reducer] = 1

def my_node(state):
    return {
        "foo": 1,
        "boo": 1,
    }

reducer = operator.add

class State1(BaseModel):
    foo: Annotated[int, reducer] = 1
    boo: Annotated[int, reducer] = 1


graph = (
    StateGraph(State1)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = State1()
print(f"The state 1 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")

graph = (
    StateGraph(State2)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = State2()
print(f"The state 2 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")

graph = (
    StateGraph(State3)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)

input_state = State3()
print(f"The state 3 output: {graph.invoke(input_state)}")
print(f"The reducer address: {reducer}")
print(f"The update lambda address: {add_one}")
@vbarda commented on GitHub (Apr 16, 2025): this is not how reducer functions are intended to be used (and i don't believe we recommend that in any of our documentation). the state update from the node is supposed to be an actual value to be used in a reducer, not a function. note that for pydantic defaults to be respected, you would currently need to invoke the graph with `State1()` etc., not with an empty dict these 3 variants give the same outputs (after changing to the proper reducer) ```python from typing import Annotated, Optional from pydantic import BaseModel, Field from langgraph.graph import StateGraph, START def reducer(current, update): return current + update class State2(BaseModel): foo: Annotated[Optional[int], reducer] = 1 boo: Annotated[int, reducer] = 1 class State3(BaseModel): foo: Annotated[int | None, reducer] = 1 boo: Annotated[int, reducer] = 1 def my_node(state): return { "foo": 1, "boo": 1, } reducer = operator.add class State1(BaseModel): foo: Annotated[int, reducer] = 1 boo: Annotated[int, reducer] = 1 graph = ( StateGraph(State1) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = State1() print(f"The state 1 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") graph = ( StateGraph(State2) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = State2() print(f"The state 2 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") graph = ( StateGraph(State3) .add_node(my_node) .add_edge(START, "my_node") .compile() ) input_state = State3() print(f"The state 3 output: {graph.invoke(input_state)}") print(f"The reducer address: {reducer}") print(f"The update lambda address: {add_one}") ```
Author
Owner

@xiangyuwang-mai commented on GitHub (Apr 17, 2025):

Thanks for the reply!

The reason we are trying to set the reducer this way is because we want to isolate the node definition from the state definition. Ideally, node should dictate how their value will be post-processed rather than the state doing so. In that case, one can create a library of nodes that are sharable across multiple graphs / states and the graph owner can effectively manage complicated merging logic in between super-steps across nodes.

I understand this is not the current design in LangGraph, and if this is also not something you'd be interested to support in the longer run, then we will explore other possible options.

@xiangyuwang-mai commented on GitHub (Apr 17, 2025): Thanks for the reply! The reason we are trying to set the reducer this way is because we want to isolate the node definition from the state definition. Ideally, node should dictate how their value will be post-processed rather than the state doing so. In that case, one can create a library of nodes that are sharable across multiple graphs / states and the graph owner can effectively manage complicated merging logic in between super-steps across nodes. I understand this is not the current design in LangGraph, and if this is also not something you'd be interested to support in the longer run, then we will explore other possible options.
Author
Owner

@xiangyuwang-mai commented on GitHub (Apr 21, 2025):

BTW, I think the issue is in this implementation: https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/channels/binop.py#L83

Whenever the state field cannot be correctly instantiated (a union type, a pydantic model with required fields, etc), the first value from the value sequence will be used as the base value and "escape" the application of the reducer. So its behavior could differ from the subsequent items if the reduce has some special treatment logic.

If this is the basic assumption, I think it's better to make it clear in the document that any custom reducer cannot introduce logic that is asymmetric, otherwise it could cause unexpected error. See the following example:

from typing import Annotated, Optional, List
import operator

from pydantic import BaseModel
from langgraph.graph import StateGraph, START, END

def append(current, update):
    return current + [update]

class State1(BaseModel):
    foo: Annotated[Optional[List[str]], append] = []

def my_node(state):
    return {
        "foo": "a"
    }

input_state = {}

graph = (
    StateGraph(State1)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)
print(f"The state 1 output: {graph.invoke(input_state)}")

The coder might expected the outcome to be {"foo": ["a"]}, but the actual output is {"foo": "a"}

@xiangyuwang-mai commented on GitHub (Apr 21, 2025): BTW, I think the issue is in this implementation: https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/channels/binop.py#L83 Whenever the state field cannot be correctly instantiated (a union type, a pydantic model with required fields, etc), the first value from the value sequence will be used as the base value and "escape" the application of the reducer. So its behavior could differ from the subsequent items if the reduce has some special treatment logic. If this is the basic assumption, I think it's better to make it clear in the document that any `custom reducer` cannot introduce logic that is asymmetric, otherwise it could cause unexpected error. See the following example: ```python from typing import Annotated, Optional, List import operator from pydantic import BaseModel from langgraph.graph import StateGraph, START, END def append(current, update): return current + [update] class State1(BaseModel): foo: Annotated[Optional[List[str]], append] = [] def my_node(state): return { "foo": "a" } input_state = {} graph = ( StateGraph(State1) .add_node(my_node) .add_edge(START, "my_node") .compile() ) print(f"The state 1 output: {graph.invoke(input_state)}") ``` The coder might expected the outcome to be `{"foo": ["a"]}`, but the actual output is `{"foo": "a"}`
Author
Owner

@xiangyuwang-mai commented on GitHub (Apr 21, 2025):

It's easier to make such mistake when using TypedDict because no default value is required:

def append(current, update):
    return current + [update]

class State2(TypedDict):
    foo: Annotated[Optional[List[str]], append]

def my_node(state):
    return {
        "foo": "a"
    }

input_state = {}

graph = (
    StateGraph(State2)
    .add_node(my_node)
    .add_edge(START, "my_node")
    .compile()
)
print(f"The state 2 output: {graph.invoke(input_state)}")

@xiangyuwang-mai commented on GitHub (Apr 21, 2025): It's easier to make such mistake when using `TypedDict` because no default value is required: ```python def append(current, update): return current + [update] class State2(TypedDict): foo: Annotated[Optional[List[str]], append] def my_node(state): return { "foo": "a" } input_state = {} graph = ( StateGraph(State2) .add_node(my_node) .add_edge(START, "my_node") .compile() ) print(f"The state 2 output: {graph.invoke(input_state)}") ```
Author
Owner

@sydney-runkle commented on GitHub (Jun 11, 2025):

Closing given @vbarda's comment above

@sydney-runkle commented on GitHub (Jun 11, 2025): Closing given @vbarda's comment above
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#585