update_state can permanently erase a thread's messages
What happened
Our chat app runs on LangGraph Platform. When a thread is paused on interrupt() and the user dismisses the question, we called update_state(values=None, as_node=END) to close the pause. On the platform (server 0.12.5, langgraph 1.2.11) that call deleted the user's entire conversation: the new head checkpoint reads messages: [] from then on, while its parent checkpoint still holds the full message list. Four messages before the call, zero after — no error, no warning.
It's not just update_state: on the same platform-shaped graph, get_state and get_state_history also read messages: [] for threads that have a full conversation. update_state is the destructive case because it folds the update against that wrongly-empty value and commits it — every later read and every later run continues from an empty thread.
The part that makes this dangerous: it cannot be caught locally. langgraph dev and any graph compiled with .compile(checkpointer=...) behave correctly, because there the graph-attached checkpointer happens to be the right one. The bug only exists when the checkpointer arrives through config — which is exactly the LangGraph Platform shape, i.e. production.
Minimal repro
import asyncio
from typing import Annotated, TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import CONFIG_KEY_CHECKPOINTER
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
async def main() -> None:
builder = StateGraph(State)
builder.add_node("model", lambda state: {})
builder.add_edge(START, "model")
builder.set_finish_point("model")
graph = builder.compile() # no checkpointer attached, as on the platform
saver = InMemorySaver()
config = {"configurable": {"thread_id": "t1", CONFIG_KEY_CHECKPOINTER: saver}}
await graph.ainvoke({"messages": [("user", "hello"), ("ai", "hi!")]}, config)
snapshot = await graph.aget_state(config)
print("get_state sees:", len(snapshot.values.get("messages", [])), "- expected 2")
await graph.aupdate_state(config, {"messages": [("user", "third")]}, as_node="model")
snapshot = await graph.aget_state(config)
print("after update_state:", len(snapshot.values.get("messages", [])), "- expected 3")
asyncio.run(main())Output on langgraph==1.2.11 and current main:
get_state sees: 0 - expected 2
after update_state: 0 - expected 3(InMemorySaver replays from the full write log, so in this repro only the reads are wrong; on the platform the emptiness of the update head persists, as described above.)
Why it happens
get_state, get_state_history, and update_state (sync and async) resolve the checkpointer from config[CONF][CONFIG_KEY_CHECKPOINTER] and use it for everything — except channel hydration, which is called with saver=self.checkpointer, the graph-attached one. On a graph compiled without a checkpointer that attribute is None.
A plain channel doesn't care. But a DeltaChannel whose value is a sentinel at that checkpoint needs the saver to replay ancestor deltas (_needs_replay → get_delta_channel_history) — and with saver=None the replay is silently skipped, so the channel hydrates empty. update_state then merges into the emptiness and writes it forward; because the emptied channel is unavailable, the update head gets no snapshot blob, so every later read of that head replays into the same emptiness.
Affected sites in langgraph/pregel/main.py (current main):
_prepare_state_snapshot/_aprepare_state_snapshot(behind the four state readers)bulk_update_state/abulk_update_state
Proposed fix
Hydrate with the checkpointer those functions already resolve and validate: in bulk_update_state / abulk_update_state use the local checkpointer; have the snapshot helpers take it as a saver parameter (falling back to self.checkpointer, so attached-checkpointer graphs are unchanged), passed from the four reader call sites.
Verified locally: with the change applied the repro prints 2 and 3, and reads through a config-injected checkpointer match a fully-attached graph on the same thread. Patch plus regression tests (tests/test_delta_channel_update_state.py) are in #8654.
Possibly related in failure smell, different site: #8448.
Source: langchain-ai/langgraph