stream_mode="values" skips a step when a node returns None or {}, while "updates" emits it
The docstrings for Pregel.stream / .astream document the two modes as:
"values": Emit all values in the state after each step, including interrupts."updates": Emit only the node or task names and updates returned by the nodes or tasks after each step.
Both say "after each step". In practice they disagree about how many steps happened when a node returns None or {}: updates emits a chunk for that node, values emits nothing.
Repro
No LLM, no network:
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
class S(TypedDict):
steps: Annotated[list, add]
n: int
def a(s): return {"steps": ["a"], "n": s["n"] + 1}
def b(s): return {"steps": ["b"], "n": s["n"] + 1}
def mid(s): return None # same result with `return {}`
g = StateGraph(S)
g.add_node("a", a); g.add_node("mid", mid); g.add_node("b", b)
g.add_edge(START, "a"); g.add_edge("a", "mid"); g.add_edge("mid", "b"); g.add_edge("b", END)
app = g.compile(checkpointer=InMemorySaver())
print(list(app.stream({"steps": [], "n": 0}, {"configurable": {"thread_id": "u"}}, stream_mode="updates")))
print(list(app.stream({"steps": [], "n": 0}, {"configurable": {"thread_id": "v"}}, stream_mode="values")))Observed
updates: [{'a': {'steps': ['a'], 'n': 1}}, {'mid': None}, {'b': {'steps': ['b'], 'n': 2}}]
values : [{'steps': [], 'n': 0}, {'steps': ['a'], 'n': 1}, {'steps': ['a', 'b'], 'n': 2}]updates has three entries (a, mid, b). values has three (initial, after a, after b) — the mid step is absent. So the usual invariant len(values) == len(updates) + 1 does not hold, and it fails silently: nothing signals that a step is missing from one view.
With stream_mode=["updates", "values"] the same asymmetry shows up as an uneven interleave rather than strict alternation:
values, updates, values, updates, updates, valueswhich is what led me here — I was pairing the two streams positionally to drive a UI, and they drifted out of alignment on exactly the no-op nodes.
Versions
Reproduces identically on:
langgraph1.2.9 /langgraph-checkpoint4.1.1langgraph1.2.11 /langgraph-checkpoint4.2.0 (latest at time of filing)
Python 3.11, Windows.
Question rather than a bug assertion
Suppressing a no-op state emission looks deliberate, and if it is, the fix is in the docstring rather than the code — "values" would be documented as "after each step that changes state". But if "after each step" is meant literally for both modes, a step that produced no writes should still emit the unchanged state.
I didn't want to guess which one is intended. Happy to send a PR for whichever you prefer.
Source: langchain-ai/langgraph