LangGraph: "'list' object has no attribute 'root'" after resume with a Pydantic RootModel in state
JsonPlusSerializer turns a RootModel into its raw value on checkpoint load. Wrap it in a BaseModel or store plain values.
Disclosure: I work on MemorySync (I'm Mohammed, the founder). I drafted this page with AI assistance and checked it against the source issue, the langgraph-checkpoint source and the Pydantic docs listed under Sources on Oct 29, 2026. I didn't run a reproducer for this page.
The question
In langchain-ai/langgraph#9480 (opened Oct 29, 2026), a graph that keeps a Pydantic v2 RootModel in state works until it's resumed from a persistent checkpointer:
class IntList(RootModel[list[int]]):
pass
class State(TypedDict):
items: IntList
def consume(state: State) -> State:
return {"items": IntList(state["items"].root + [3])}
The first invoke stops at an interrupt(). Resuming with Command(resume="go") on a SqliteSaver fails with:
AttributeError: 'list' object has no attribute 'root'
The serializer alone shows it: serde.loads_typed(serde.dumps_typed(IntList([1, 2, 3]))) comes back as a plain list. Reported on langgraph 1.2.18 and langgraph-checkpoint 4.3.1 (the latest checkpoint release on PyPI as of Oct 29, 2026), still the latest checkpoint release on Nov 7, 2026.
Status (checked Nov 7, 2026): open, 4 comments, no merged fix. A community PR (#9496) was closed unmerged on Oct 30, and a commenter who verified the bug says a fix with RootModel regression tests is ready. langgraph-checkpoint 4.3.1 is still the latest release, and its jsonplus.py still rebuilds Pydantic v2 models with cls(**...).
Skip the memory plumbing around this fix. Once the fix below is in, the MemorySync LangGraph guide shows how to add per-user memory with MemorySync. MemorySync doesn't fix this error itself. When you're ready, see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).
Why it happens
In langgraph/checkpoint/serde/jsonplus.py, Pydantic v2 objects are written as EXT_PYDANTIC_V2 with obj.model_dump() as the payload. On load, the code imports the class and calls cls(**tup[2]), falling back to cls.model_construct(**tup[2]); if that also fails, the outer handler hands back the raw payload.
For a RootModel, model_dump() returns the root value itself (a list, string, int...), not a dict. cls(**[1, 2, 3]) can't work, so you get the bare list. Inside one run you don't notice, because the in-memory channel still holds the original object. After a checkpoint round trip (get_state(), or resume after interrupt), the next node gets the list.
Fix 1: wrap the root value in a normal model
Regular BaseModel subclasses dump to a dict and rebuild through cls(**payload), which is the path that works today. The issue's related reports (#9343 aliased fields, #6102 generic models) cover other edge cases, so keep the wrapper plain:
from pydantic import BaseModel
class IntList(BaseModel):
items: list[int]
class State(TypedDict):
items: IntList
def consume(state: State) -> State:
return {"items": IntList(items=state["items"].items + [3])}
Fix 2: store the plain value, validate at the edges
Keep list[int] in graph state and build the RootModel inside the node when you need it:
class State(TypedDict):
items: list[int]
def consume(state: State) -> State:
items = IntList.model_validate(state["items"])
return {"items": items.root + [3]}
model_validate on a RootModel takes the root value directly (Pydantic docs on RootModel).
Fix 3: patch the load path (what the issue proposes)
The reporter's suggested change, not merged as of Nov 7, 2026, rebuilds root models with model_validate instead of cls(**payload):
cls = getattr(importlib.import_module(tup[0]), tup[1])
if getattr(cls, "__pydantic_root_model__", False):
return cls.model_validate(tup[2])
They say it keeps the existing payload format, so checkpoints already written would load correctly once it ships. Until then, Fix 1 or Fix 2 avoids carrying a fork.
Checklist
- Search your state schemas for
RootModelsubclasses. - Swap them for a wrapped
BaseModel(Fix 1) or plain values (Fix 2). - Old checkpoints already hold the raw value, so make the resuming node accept both shapes, or start new threads.
- If
get_state()returns plain values for other models, check the log: the serializer logs "Blocked deserialization of ..." when a class isn't inallowed_msgpack_modules. - Watch #9480 for a fixed langgraph-checkpoint release.
Common questions
Why does it only fail after an interrupt? Within one run, the channel keeps the original object. Resume reads from the checkpoint, which is where the type is lost.
Does switching from SQLite to Postgres help?
The issue points at the shared JsonPlusSerializer, not the SQLite saver, so expect the same result with other savers that use it.
Is this the same as the generic model issue?
No. #6102 covers generic Pydantic models and #9343 aliased fields; this one is RootModel only.
Skip the plumbing with MemorySync
Disclosure: I build MemorySync.
It doesn't fix this. The MemorySync LangGraph guide says thread state (checkpoints, resume, time travel) belongs to a checkpointer such as langgraph-checkpoint-postgres, -sqlite or -redis, and MemorySync isn't one. What it 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. Read the guide first. For an overview of MemorySync, see the MemorySync homepage.
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; I haven't run it for this page.
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.
Sources
- github.com/langchain-ai/langgraph/issues/9480 (opened 2026-10-29, open, 4 comments; read 2026-11-07)
- langgraph-checkpoint source: libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py, main branch (
EXT_PYDANTIC_V2dump/load; read 2026-10-29) - docs.pydantic.dev/latest/concepts/models/ (
RootModel; read 2026-10-29) - MemorySync docs: docs.memorysync.io/guides/langgraph
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.
