LangGraph 'Could not reconstruct builtins.set from checkpoint': why a set of tuples comes back as a list
A set of tuples in LangGraph state comes back from a checkpoint as a list of lists (or None on langgraph-checkpoint 4.2.0). Why the serializer can't rebuild it, and a state shape that round-trips, tested on langgraph 1.2.20.
Disclosure: I work on MemorySync (I'm Mohammed, the founder). I drafted this article with AI assistance and checked it against the GitHub issue, the langgraph-checkpoint source, the published LangGraph packages and the MemorySync docs listed under Sources on Nov 1, 2026, and rechecked the issue, the PyPI releases and jsonplus.py on main on Nov 7, 2026. Code status: I ran every code block on this page offline with outbound connections blocked and no API keys, on Linux with langgraph 1.2.20, langgraph-checkpoint 4.3.1 and langgraph-checkpoint-sqlite 3.1.3 (the latest releases on Nov 7, Python 3.13.18), and earlier with langgraph 1.2.19 (Python 3.13.5). I also ran the reproduction and the fix on langgraph-checkpoint 4.3.0 and 4.2.0. No model is called in these graphs. I didn't run anything on Windows or macOS, or with the Postgres or Redis checkpointers, and I didn't run the MemorySync snippet against the API (offline, its first call failed on the blocked network).
Your LangGraph state has a field like visited: set[tuple[int, int]]. Everything looks fine until LangGraph reads that state back from a checkpoint, for example when the graph pauses on interrupt() and you resume it. Then the log says:
Could not reconstruct builtins.set from checkpoint (TypeError); returning its serialized data instead.
And the set is now a list of lists, [[0, 1], [0, 0]]. That's LangGraph issue #9516, "Set of tuples in state is restored as a list from a checkpoint".
Short version: the checkpoint serializer can't rebuild a set whose items are tuples. The tuples are saved as lists, a list can't go in a set, so the rebuild fails and you get the saved list back. On langgraph-checkpoint 4.2.0 it was quieter: no warning, and the field came back as None. Until the serializer changes, keep the field as a list of [row, col] lists in state and build the set inside your node (Step 2). That worked on every version I tried.
MemorySync doesn't fix this error. It's about how LangGraph's checkpoint serializer stores a Python type, and MemorySync isn't a checkpointer. It comes up once, near the end, for a different job: long-term memory across threads.
Skip the plumbing for cross-thread memory. The MemorySync LangGraph guide shows
MemorySyncStore, a LangGraphBaseStorefor memory that outlives a thread. Your checkpointer still owns thread state, including this field. When you're ready, see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).
Status (Nov 7, 2026)
- #9516 was opened on Oct 31, 2026 (21:35 IST) by an outside contributor, with a reproduction on
langgraph1.2.19 andlanggraph-checkpoint4.3.1. It's still open with 4 comments and no assignee: two other users confirmed it independently (one on macOS, one onmain), and the reporter opened a fix as PR #9572 on Nov 4 (turn lists inside a decodedsetorfrozensetback into tuples). A bot closed that PR automatically within a minute because external contributors must be assigned to the issue first; it wasn't merged. The reporter has asked to be assigned. No maintainer has commented on the issue. - The latest releases on PyPI on Nov 7,
langgraph-checkpoint4.3.1 (published Oct 27, 2026, 01:27 IST) andlanggraph1.2.20 (Nov 5, 07:50 IST), behave as the issue says.jsonplus.pyon themainbranch was unchanged on Nov 7 (its last change is from Oct 9), so no fix has landed. - The warning is new in 4.3.0 (Oct 13, 2026). Comparing the 4.2.0 and 4.3.0 source, 4.2.0 returned
Nonefor any value it couldn't rebuild; 4.3.0 added a fallback that logs this warning and returns the saved data instead, with a code comment sayingNone"would silently erase the value from restored state." - In my runs,
InMemorySaverandSqliteSaver(resuming in a second process) behaved the same on each version.
Why it happens
The checkpointer doesn't keep your Python objects. It serializes state with LangGraph's JsonPlusSerializer, which uses msgpack. A set or frozenset is saved as its type name plus its items, and msgpack has no tuple type, so each (0, 1) is written as an array and read back as the list [0, 1]. When the checkpoint is loaded, the serializer calls set([[0, 1], [0, 0]]), which raises TypeError: unhashable type: 'list', and the fallback hands you the list instead.
What I saw with the same serializer on 4.3.1:
| Value in state | Read back from the checkpoint |
|---|---|
{1, 2} or {"a", "b"} |
the same set |
{(0, 0), (0, 1)} |
[[0, 1], [0, 0]], with the warning |
frozenset({(1, 2)}) |
[[1, 2]], with the same warning for builtins.frozenset |
[(1, 2)] or (1, 2) |
lists: [[1, 2]], [1, 2] (no warning) |
{(1, 2): "x"} (dict with tuple keys) |
the same dict |
So sets of strings and ints are fine. Tuples anywhere come back as lists, but only inside a set does that make the rebuild fail. Passing JsonPlusSerializer(pickle_fallback=True) didn't change the result: the set still came back as a list. The warning is logged once per type per process, so in a busy app you may see it once and miss it.
Step 1: Reproduce it
This is the reproduction from the issue, which I ran as-is:
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict):
visited: set[tuple[int, int]]
def explore(state: State) -> State:
return {"visited": {(0, 0), (0, 1)}}
def ask(state: State) -> State:
interrupt("continue?")
return {}
builder = StateGraph(State)
builder.add_node("explore", explore)
builder.add_node("ask", ask)
builder.add_edge(START, "explore")
builder.add_edge("explore", "ask")
builder.add_edge("ask", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"visited": set()}, config)
visited = graph.get_state(config).values["visited"]
print(type(visited).__name__, visited)
result = graph.invoke(Command(resume=True), config)
print(type(result["visited"]).__name__, result["visited"])
Output on 4.3.1 (4.3.0 is the same):
Could not reconstruct builtins.set from checkpoint (TypeError); returning its serialized data instead.
list [[0, 1], [0, 0]]
list [[0, 1], [0, 0]]
On 4.2.0, no warning:
NoneType None
NoneType None
The node code after the resume is where it breaks. When the ask node checked (0, 0) in state["visited"] after the resume, it got False on 4.3.1, because a list of lists doesn't contain a tuple, and then state["visited"].add(...) raised AttributeError: 'list' object has no attribute 'add'. On 4.2.0 the same check raised TypeError: argument of type 'NoneType' is not iterable.
Step 2: Keep a list in state, build the set in the node
Store what the serializer can round-trip exactly, a list of [row, col] lists, and turn it into a set of tuples only inside the node that uses it:
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
Cell = tuple[int, int]
class State(TypedDict):
# A sorted list of [row, col] pairs. Lists of ints survive a checkpoint as-is.
visited: list[list[int]]
def load_cells(state: State) -> set[Cell]:
return {(row, col) for row, col in state.get("visited") or []}
def dump_cells(cells: set[Cell]) -> list[list[int]]:
return [list(cell) for cell in sorted(cells)]
def explore(state: State) -> State:
cells = load_cells(state) | {(0, 0), (0, 1)}
return {"visited": dump_cells(cells)}
def ask(state: State) -> State:
interrupt("continue?")
cells = load_cells(state) # a real set again, after the resume
print("(0, 0) seen:", (0, 0) in cells)
cells.add((1, 1))
return {"visited": dump_cells(cells)}
builder = StateGraph(State)
builder.add_node("explore", explore)
builder.add_node("ask", ask)
builder.add_edge(START, "explore")
builder.add_edge("explore", "ask")
builder.add_edge("ask", END)
graph = builder.compile(checkpointer=InMemorySaver())
if __name__ == "__main__":
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"visited": []}, config)
print(graph.get_state(config).values["visited"])
result = graph.invoke(Command(resume=True), config)
print(result["visited"])
Output, the same on 4.3.1, 4.3.0 and 4.2.0, with no warning:
[[0, 0], [0, 1]]
(0, 0) seen: True
[[0, 0], [0, 1], [1, 1]]
What each part does:
visited: list[list[int]]is a type that comes back from a checkpoint exactly as it went in.load_cells()rebuilds the set of tuples at the start of a node, so lookups like(0, 0) in cellswork again.dump_cells()sorts before saving, so the same set is always saved as the same list, which makes checkpoints easier to compare.- Every node that writes the field returns the whole list. If several nodes or parallel branches write it in one step, you'll need a reducer on the field. I didn't test one here.
Threads saved before the change still hold the set. I paused a thread with the Step 1 graph, then resumed it in a new process with the Step 2 graph (same field name, SqliteSaver). On 4.3.1 and 4.3.0, load_cells() read the returned [[0, 1], [0, 0]] list, so the old cells carried over (the warning still appeared once for that old checkpoint). On 4.2.0 the old value came back as None, load_cells() treated it as an empty set, and the resumed run ended with only [[1, 1]]: the old cells were lost.
Step 3: Check it across a restart
InMemorySaver keeps everything in one process. To check the fix survives a real restart, I compiled the same graph with SqliteSaver and ran the start and the resume as two separate processes:
# Run twice as separate processes: `python step3_restart.py start`, then `python step3_restart.py resume`.
import sqlite3
import sys
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
import step2_fix
conn = sqlite3.connect("checkpoints.db", check_same_thread=False)
graph = step2_fix.builder.compile(checkpointer=SqliteSaver(conn))
config = {"configurable": {"thread_id": "maze-42"}}
if sys.argv[1] == "start":
graph.invoke({"visited": []}, config)
print("paused with", graph.get_state(config).values["visited"])
else:
print("resumed with", graph.invoke(Command(resume=True), config)["visited"])
Run python step3_restart.py start, then python step3_restart.py resume. Output (needs pip install langgraph-checkpoint-sqlite):
paused with [[0, 0], [0, 1]]
(0, 0) seen: True
resumed with [[0, 0], [0, 1], [1, 1]]
I got that on all three versions. The Step 1 graph with SqliteSaver printed the warning and the list (or None on 4.2.0) in both processes, the same as with InMemorySaver.
What about a Pydantic state?
If your state is a Pydantic BaseModel with visited: set[tuple[int, int]], my run on 4.3.1 was mixed: the node after the resume received a real set of tuples, because Pydantic converted the list when it built the state, but the warning still appeared and graph.get_state(config).values["visited"] was still a list. On 4.2.0 the same graph failed on resume with a Pydantic ValidationError ("Input should be a valid set", input None). Step 2 works the same way on every version, so I'd use it either way.
Skip the plumbing with MemorySync
Disclosure: I build MemorySync.
It doesn't fix this, and it doesn't replace your checkpointer. The MemorySync LangGraph guide says thread state (checkpoints, resume, time travel) belongs to a checkpointer such as langgraph-checkpoint-postgres, -sqlite or -redis, so a set in your graph state goes through the same serializer whether you use MemorySync or not. What MemorySync adds is long-term memory across threads: MemorySyncStore, a LangGraph BaseStore you compile in with store=, plus pre-model context injection and a search tool. That helps if the data in your state is really something about a user that should carry over to their next conversation.
From the guide:
Unverified (not run):
pip install "langchain-memorysync[langgraph]"
from langchain_memorysync.langgraph import MemorySyncStore
store = MemorySyncStore() # MEMORYSYNC_API_KEY from the env
store.put(("memories", "user-1"), "diet", {"text": "vegetarian"})
item = store.get(("memories", "user-1"), "diet")
print(item.value) # {'text': 'vegetarian'}
hits = store.search(("memories", "user-1"), query="what do they eat?")
print(hits[0].value, hits[0].score)
# Filters use the same JSONB-style operators as LangGraph's own stores.
store.search(("memories", "user-1"), filter={"confidence": {"$gte": 0.8}})
Snippet adapted from the MemorySync LangGraph guide. With langchain-memorysync 1.2.0, I ran it offline with a placeholder key and the first call failed on the blocked network, as expected. I haven't run it against the API.
Try it: MemorySync is optional here: the steps above work without it. If you want the memory layer from the snippet, see MemorySync, create an API key in the dashboard and drop the snippet into your agent.
Troubleshooting
- The warning names
builtins.frozenset. Same cause, same fix: a frozenset of tuples comes back as a list. - No warning, but the field is
Noneafter a resume. Checkpip show langgraph-checkpoint. Before 4.3.0, a value the serializer couldn't rebuild came back asNone. AttributeError: 'list' object has no attribute 'add'after an interrupt. The node got the list back. Use Step 2, or convert with{tuple(x) for x in value}at the start of the node.- The warning appeared once and never again. It's logged once per type per process. Check other threads too.
- A different type in the warning. This page only covers sets and frozensets of tuples. Other "Could not reconstruct" warnings have other causes I didn't test.
Common questions
Will LangGraph fix this? Maybe. The issue is open and the reporter has proposed a fix, but the reporter's PR was auto-closed unmerged and no maintainer had replied when I checked on Nov 7, 2026. Step 2 doesn't depend on the outcome.
Is my data gone?
On 4.3.x, no: the saved list of lists is returned, and {tuple(x) for x in value} turns it back into the set. Values that were read back as None on 4.2.0 aren't recoverable from that return value.
Does this affect PostgresSaver or RedisSaver?
I didn't test them. They're separate packages, and whether they behave the same depends on whether they use the same serializer.
Will MemorySync fix this error? No. It's how LangGraph's checkpointer serializes graph state, which MemorySync doesn't touch. MemorySync is an option for long-term memory across threads, next to your checkpointer.
Related pages
- LangGraph memory explained: checkpointer vs store, and per-user memory across threads
- LangGraph AsyncPostgresSaver: fixing 'psycopg.OperationalError: the connection is closed' in FastAPI
- LangGraph SummarizationMiddleware wiped my chat history: keeping the full transcript with PostgresSaver
- LangGraph: "'list' object has no attribute 'root'" after resume with a Pydantic RootModel in state
Sources
- GitHub issue langchain-ai/langgraph#9516, "Set of tuples in state is restored as a list from a checkpoint" (opened 2026-10-31 21:35 IST, open, 4 comments, no assignee, label external; read via the GitHub API on 2026-11-07)
- GitHub PR langchain-ai/langgraph#9572, "fix(checkpoint): restore tuples when rebuilding sets from msgpack" (opened 2026-11-04 01:33 IST, auto-closed unmerged the same minute; read 2026-11-07)
- PyPI: langgraph 1.2.20 (published 2026-11-05 07:50 IST), 1.2.19 (2026-10-31 03:53 IST), langgraph-checkpoint 4.3.1 (2026-10-27 01:27 IST), 4.3.0 (2026-10-13 03:56 IST) and 4.2.0 (2026-08-08 01:35 IST), langgraph-checkpoint-sqlite 3.1.3
- langgraph-checkpoint source:
libs/checkpoint/langgraph/checkpoint/serde/jsonplus.pyonmain(read 2026-11-01 and 2026-11-07, unchanged) and in the 4.3.1, 4.3.0 and 4.2.0 packages - LangGraph docs: Persistence
- Offline runs (2026-11-01, Linux, Python 3.13.5, network blocked, no keys; re-run 2026-11-07 on langgraph 1.2.20 with langgraph-checkpoint 4.3.1, Python 3.13.18, same results): Step 1 with
InMemorySaverand withSqliteSaveracross two processes; node code after the resume; an old set-of-tuples thread resumed by the Step 2 graph; serializer round trips; Pydantic state; Step 2; Step 3 as two processes; each on langgraph-checkpoint 4.3.1 (langgraph 1.2.19), 4.3.0 (langgraph 1.2.18) and 4.2.0 (langgraph 1.2.14), ormsgpack 1.13.0, pydantic 2.14.2; MemorySync snippet withlangchain-memorysync1.2.0, network blocked - MemorySync LangGraph guide: docs.memorysync.io/guides/langgraph (fetched 2026-11-01)
Try it
For an overview of MemorySync, see the MemorySync homepage.
Next step: read the MemorySync LangGraph guide, then see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).
About the author
- Building MemorySync (memorysync.io) — persistent memory infrastructure for AI agents, Cursor, and Claude Code.
Comments
Join the discussionNo comments yet. Be the first to comment.
