LangGraph Checkpointer vs Store: Why Your Agent Forgets Across Threads
Wire up a LangGraph checkpointer, see state survive a conversation, assume it is memory, then watch the agent forget the user the next day. The checkpointer is thread-scoped short-term state; the store is cross-thread long-term memory. The precise distinction, the real API for both, and where a store stops being enough.
Almost every "why does my LangGraph agent forget me" question comes down to one confusion: people wire up a checkpointer, see state survive within a conversation, and assume that is memory. Then a user comes back the next day, the agent has no idea who they are, and the bug report says the checkpointer is broken. It is not. The checkpointer is doing exactly its job, which is not the job people think it is. LangGraph has two persistence primitives that look similar and do completely different things: the checkpointer and the store. This article draws the line between them precisely, with the actual API, so you stop reaching for the wrong one.
The one-sentence version
The checkpointer persists the state of a single thread. The store persists facts across all threads. If you only have a checkpointer, your agent remembers within a conversation and forgets between conversations, because a new conversation is a new thread, and a different thread has its own separate checkpoint.
The checkpointer: short-term, thread-scoped state
A checkpointer is the backend LangGraph uses to snapshot graph state. The detail people miss is the frequency: it saves a checkpoint after every super-step, not just at the end of a run. That is what makes a graph resumable mid-flight, replayable, and interruptible for human-in-the-loop review. State is bound to a thread_id you pass in the config.
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# turn 1
graph.invoke({"messages": [user("my name is Dana")]},
config={"configurable": {"thread_id": "conversation-42"}})
# turn 2, same thread, state is restored
graph.invoke({"messages": [user("what's my name?")]},
config={"configurable": {"thread_id": "conversation-42"}})
# -> "Dana", because thread-42's checkpoint still holds the messagesSwap InMemorySaver for a durable backend in production. There are official savers for Postgres, Redis, SQLite, MongoDB, and more, all behind the same BaseCheckpointSaver interface. But durability of the saver is not the point. Even with a Postgres checkpointer, the state is still scoped to a thread. Ask for a different thread_id and you get a different, empty conversation.
# new day, new conversation = new thread_id
graph.invoke({"messages": [user("what's my name?")]},
config={"configurable": {"thread_id": "conversation-43"}})
# -> the agent has no idea. thread-43 never heard "Dana".This is not a failure. It is the design. A checkpointer answers "resume this exact conversation," and it does that well. It was never meant to answer "what do I know about this user across every conversation."
The store: long-term, cross-thread memory
The store is the other primitive. It is a persistent key-value system organized under namespaces, and it is explicitly not thread-scoped. A namespace is a tuple, conventionally including a stable identifier such as the user or org id, which is precisely what lets a fact survive across thread boundaries.
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
graph = builder.compile(checkpointer=checkpointer, store=store)Note that line: you compile with both. That is the normal production shape, not an either-or. Inside a node, reach the store with get_store() and read or write under a namespace keyed by the user, never by the thread:
from langgraph.config import get_store
def remember_node(state, config):
store = get_store()
user_id = config["configurable"]["user_id"]
namespace = (user_id, "facts") # tuple namespace, NOT thread-scoped
store.put(namespace, "name", {"value": "Dana"})
return state
def recall_node(state, config):
store = get_store()
user_id = config["configurable"]["user_id"]
item = store.get((user_id, "facts"), "name")
# item.value -> {"value": "Dana"} in ANY thread for this userBecause the namespace is the user id rather than the thread id, the next day's brand new thread_id still resolves the same fact. That is the difference between resuming a conversation and remembering a person.
Search and semantic recall
The store exposes put(namespace, key, value), get(namespace, key), and search(namespace, query=...). If you initialize InMemoryStore with an index, searchbecomes semantic, returning items ranked by embedding similarity rather than exact key match:
store = InMemoryStore(index={"embed": embed_fn, "dims": 1536})
# later, inside a node:
hits = store.search((user_id, "facts"), query="dietary preferences")This is where many teams stop and call it "agent memory." It is genuinely more than a checkpointer gives you. But raw put and search is still a storage interface, not a memory policy: it does not decide what is worth remembering, it does not deduplicate two phrasings of the same fact, and it does not record that a new fact supersedes an old one.
The mapping people get wrong
Lining the two primitives up against the questions they actually answer makes the choice mechanical:
- Resume this exact conversation after an interrupt, a crash, or a human review step: checkpointer, keyed by
thread_id. - Time travel and replay a run from an earlier step to branch or debug: checkpointer, which keeps the per-step snapshots.
- Remember a user across sessions so preferences and facts persist into next week's new thread: store, keyed by a stable namespace.
- Share a learned fact between threads or agents: store, never a checkpointer, because checkpoints are isolated per thread.
The recurring bug is using thread_id as if it were a user id. If you make every session reuse one long-lived thread_id to fake persistence, the checkpoint grows without bound, every old turn replays into the window, and you have rebuilt the context-rot problem instead of solving the memory problem. Use the checkpointer for threads and the store for identity. They are not interchangeable.
Where the store ends and a memory layer begins
The checkpointer-versus-store split is the right mental model, and for many agents the store is enough. But the store is a place to put things, not a discipline for what to keep. Three jobs sit above raw put/searchthat an agent at scale eventually needs:
- Extraction. Deciding which turns contain a durable fact worth writing, instead of dumping every message into the store.
- Supersession. When a user says "actually, call me Dan now," the old value should be visibly corrected, not left to compete with the new one at recall time.
- Provenance. Being able to ask where a recalled fact came from, so a wrong recall is traceable rather than mysterious.
LangGraph's own LangMem exists precisely to add extraction and consolidation on top of the store, which is a tacit admission that the store alone is storage, not memory policy. A dedicated memory layer like Memnode takes the same position: keep LangGraph's checkpointer for thread state, and put durable facts behind a layer that handles extraction, supersession, and inspectable lineage rather than leaving each of those to ad-hoc node code.
What to take away
If you remember one thing: the checkpointer is thread-scoped short-term state, the store is cross-thread long-term memory, and an agent that "forgets between sessions" almost always has a checkpointer and no store. Compile with both, key the checkpointer by thread_id and the store by a stable user namespace, and treat the store as the floor of memory rather than the ceiling. Once you need to decide what to keep, correct what changed, and explain what you recalled, that is a memory layer's job, not a key-value store's.