#8653·langgraph

update_state can permanently erase a thread's messages

Author: doniyor2109Created Aug 19, 2026Updated Sep 17, 2026
Labelsexternal

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

python
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_replayget_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.