Partial Graph State Missing Due to Mixed thread_id Formats in checkpoint_writes #1102

Open
opened 2026-02-20 17:43:05 -05:00 by yindo · 0 comments
Owner

Originally created by @lfge-hash on GitHub (Dec 23, 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 dataclasses import dataclass
from typing import Literal, Optional, TypedDict

from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


@dataclass
class Context:
    """Context schema defined by the developer."""
    user_id: str
    db_connection: str

class ApprovalState(TypedDict):
    action_details: str
    status: Optional[Literal["pending", "approved", "rejected"]]


def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]:
    # Expose details so the caller can render them in a UI
    decision = interrupt({
        "question": "Approve this action?",
        "details": state["action_details"],
    })

    # Route to the appropriate node after resume
    return Command(goto="proceed" if decision else "cancel")


def proceed_node(state: ApprovalState):
    return {"status": "approved"}


def cancel_node(state: ApprovalState):
    return {"status": "rejected"}


builder = StateGraph(ApprovalState)
builder.add_node("approval", approval_node)
builder.add_node("proceed", proceed_node)
builder.add_node("cancel", cancel_node)
builder.add_edge(START, "approval")
builder.add_edge("proceed", END)
builder.add_edge("cancel", END)

# Use a more durable checkpointer in production
# checkpointer = MemorySaver()
checkpointer_cm = PostgresSaver.from_conn_string(
        "postgresql://xxx:xxx@localhost:5432/postgres"
    )
with checkpointer_cm as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "approval-123"}}
    initial = graph.invoke(
        {"action_details": "Transfer $500", "status": "pending"},
        config=config,
    )
    print(initial["__interrupt__"])  # -> [Interrupt(value={'question': ..., 'details': ...})]

    result = graph.get_state(config)

    # Resume with the decision; True routes to proceed, False to cancel
    resumed = graph.invoke(Command(resume=True), config=config)

    print(resumed["status"])  # -> "approved"

    result = graph.get_state(config)

    resumed = graph.invoke(Command(resume=True), config=config)

    print(resumed["status"])  # -> "approved"

    result = graph.get_state(config)

    print("test")

Error Message and Stack Trace (if applicable)

thread_id          | checkpoint_ns |            checkpoint_id             |               task_id                | idx |      channel       |  type   |
                                                                            blob
      task_path
----------------------------+---------------+--------------------------------------+--------------------------------------+-----+--------------------+---------+----
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
------------------------
 approval-123               |               | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 |   0 | action_details     | msgpack | \xa

_pregel_pull, __start__
 approval-123               |               | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 |   1 | status             | msgpack | \xa

_pregel_pull, __start__
 approval-123               |               | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 |   2 | branch:to:approval | null    | \x

_pregel_pull, __start__
 approval-123               |               | 1f0e0728-95e2-66a2-8000-885ac3f329f9 | d01e943c-7a45-f535-002f-4e2bb0d1ba12 |  -3 | __interrupt__      | msgpack | \x9
465727275707482a576616c756582a87175657374696f6eb4417070726f7665207468697320616374696f6e3fa764657461696c73ad5472616e736665722024353030a26964d920353539643733346265316
_pregel_pull, approval
 \x617070726f76616c2d313233 |               | 1f0e0728-95e2-66a2-8000-885ac3f329f9 | d01e943c-7a45-f535-002f-4e2bb0d1ba12 |  -4 | __resume__         | msgpack | \x9

_pregel_pull, approval

Description

When using PostgreSQL as the checkpointer storage in LangGraph, after an interruption occurs, the checkpoint_writes table contains two forms of thread_id: one stored as a plain string and the other stored in hexadecimal-encoded form. Although they represent the same logical thread_id, when retrieving the graph state using the string-form thread_id, some of the state is missing because part of it is stored under the hexadecimal-encoded form.

System Info

System Information

OS: Windows
OS Version: 10.0.26100
Python Version: 3.11.14 | packaged by Anaconda, Inc. | (main, Oct 21 2025, 18:30:03) [MSC v.1929 64 bit (AMD64)]

Package Information

langchain_core: 1.1.2
langchain: 1.1.3
langsmith: 0.4.58
langchain_mcp_adapters: 0.1.14
langchain_openai: 1.1.1
langgraph_sdk: 0.2.14

Optional packages not installed

langserve

Other Dependencies

httpx: 0.28.1
jsonpatch: 1.33
langgraph: 1.0.4
mcp: 1.11.0
openai: 2.9.0
opentelemetry-api: 1.39.0
opentelemetry-exporter-otlp-proto-http: 1.39.0
opentelemetry-sdk: 1.39.0
orjson: 3.11.5
packaging: 24.2
pydantic: 2.11.7
pyyaml: 6.0.2
requests: 2.32.5
requests-toolbelt: 1.0.0
rich: 14.2.0
tenacity: 9.1.2
tiktoken: 0.12.0
typing-extensions: 4.15.0
uuid-utils: 0.12.0
zstandard: 0.25.

Originally created by @lfge-hash on GitHub (Dec 23, 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 dataclasses import dataclass from typing import Literal, Optional, TypedDict from langgraph.checkpoint.postgres import PostgresSaver from langgraph.graph import StateGraph, START, END from langgraph.types import Command, interrupt @dataclass class Context: """Context schema defined by the developer.""" user_id: str db_connection: str class ApprovalState(TypedDict): action_details: str status: Optional[Literal["pending", "approved", "rejected"]] def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]: # Expose details so the caller can render them in a UI decision = interrupt({ "question": "Approve this action?", "details": state["action_details"], }) # Route to the appropriate node after resume return Command(goto="proceed" if decision else "cancel") def proceed_node(state: ApprovalState): return {"status": "approved"} def cancel_node(state: ApprovalState): return {"status": "rejected"} builder = StateGraph(ApprovalState) builder.add_node("approval", approval_node) builder.add_node("proceed", proceed_node) builder.add_node("cancel", cancel_node) builder.add_edge(START, "approval") builder.add_edge("proceed", END) builder.add_edge("cancel", END) # Use a more durable checkpointer in production # checkpointer = MemorySaver() checkpointer_cm = PostgresSaver.from_conn_string( "postgresql://xxx:xxx@localhost:5432/postgres" ) with checkpointer_cm as checkpointer: graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "approval-123"}} initial = graph.invoke( {"action_details": "Transfer $500", "status": "pending"}, config=config, ) print(initial["__interrupt__"]) # -> [Interrupt(value={'question': ..., 'details': ...})] result = graph.get_state(config) # Resume with the decision; True routes to proceed, False to cancel resumed = graph.invoke(Command(resume=True), config=config) print(resumed["status"]) # -> "approved" result = graph.get_state(config) resumed = graph.invoke(Command(resume=True), config=config) print(resumed["status"]) # -> "approved" result = graph.get_state(config) print("test") ``` ### Error Message and Stack Trace (if applicable) ```shell thread_id | checkpoint_ns | checkpoint_id | task_id | idx | channel | type | blob task_path ----------------------------+---------------+--------------------------------------+--------------------------------------+-----+--------------------+---------+---- -------------------------------------------------------------------------------------------------------------------------------------------------------------------- ------------------------ approval-123 | | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 | 0 | action_details | msgpack | \xa _pregel_pull, __start__ approval-123 | | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 | 1 | status | msgpack | \xa _pregel_pull, __start__ approval-123 | | 1f0e0728-95db-6158-bfff-aae7f05d1fa1 | c9699148-6d9d-4d35-3417-efc0e235fda8 | 2 | branch:to:approval | null | \x _pregel_pull, __start__ approval-123 | | 1f0e0728-95e2-66a2-8000-885ac3f329f9 | d01e943c-7a45-f535-002f-4e2bb0d1ba12 | -3 | __interrupt__ | msgpack | \x9 465727275707482a576616c756582a87175657374696f6eb4417070726f7665207468697320616374696f6e3fa764657461696c73ad5472616e736665722024353030a26964d920353539643733346265316 _pregel_pull, approval \x617070726f76616c2d313233 | | 1f0e0728-95e2-66a2-8000-885ac3f329f9 | d01e943c-7a45-f535-002f-4e2bb0d1ba12 | -4 | __resume__ | msgpack | \x9 _pregel_pull, approval ``` ### Description When using PostgreSQL as the checkpointer storage in LangGraph, after an interruption occurs, the checkpoint_writes table contains two forms of thread_id: one stored as a plain string and the other stored in hexadecimal-encoded form. Although they represent the same logical thread_id, when retrieving the graph state using the string-form thread_id, some of the state is missing because part of it is stored under the hexadecimal-encoded form. ### System Info System Information ------------------ > OS: Windows > OS Version: 10.0.26100 > Python Version: 3.11.14 | packaged by Anaconda, Inc. | (main, Oct 21 2025, 18:30:03) [MSC v.1929 64 bit (AMD64)] Package Information ------------------- > langchain_core: 1.1.2 > langchain: 1.1.3 > langsmith: 0.4.58 > langchain_mcp_adapters: 0.1.14 > langchain_openai: 1.1.1 > langgraph_sdk: 0.2.14 Optional packages not installed ------------------------------- > langserve Other Dependencies ------------------ > httpx: 0.28.1 > jsonpatch: 1.33 > langgraph: 1.0.4 > mcp: 1.11.0 > openai: 2.9.0 > opentelemetry-api: 1.39.0 > opentelemetry-exporter-otlp-proto-http: 1.39.0 > opentelemetry-sdk: 1.39.0 > orjson: 3.11.5 > packaging: 24.2 > pydantic: 2.11.7 > pyyaml: 6.0.2 > requests: 2.32.5 > requests-toolbelt: 1.0.0 > rich: 14.2.0 > tenacity: 9.1.2 > tiktoken: 0.12.0 > typing-extensions: 4.15.0 > uuid-utils: 0.12.0 > zstandard: 0.25.
yindo added the bugpending labels 2026-02-20 17:43:05 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#1102