TL;DR
A checkpointer persists one thread's working state; a Store persists facts across all threads. They are different objects with different lifetimes, and conflating them is the most common architectural mistake in LangGraph applications — "the assistant forgets my preferences between conversations" is almost always a checkpointer being asked to do a store's job. A store is a namespaced key-value collection: store.put(("ana", "preferences"), "seat", {"value": "aisle"}), retrieved with get, browsed with search, and injected into a node by declaring a store: BaseStore parameter. The namespace is a tuple, and it is your isolation boundary — leading it with a user id is what stops one customer's memory reaching another. The boundary of the whole technique: a store is durable storage, not judgement. It will faithfully remember a preference the user stated once, sarcastically, in 2023, and keep applying it forever, because deciding what deserves remembering and when a memory has expired is your problem, not the framework's.
Simple explanation + analogy
Picture a clinic.
The checkpointer is the notes taken during one appointment. Everything said, in order, exactly as it happened — and scoped to that visit. Perfect for continuing a conversation you are in the middle of.
The store is the patient's file. It does not contain the transcript of every visit; it contains the durable facts extracted from them: allergies, chronic conditions, what was tried and did not work. It follows the patient across every future appointment, with any clinician.
Now the mistake, which the analogy makes obvious. If you write "allergic to penicillin" only in today's appointment notes, then next month a different clinician opens a fresh set of notes and the allergy is invisible. Nothing was lost — it is right there in last month's notes — but nothing goes looking through old appointment transcripts before every visit. The fact was recorded in the wrong place. That is exactly what happens when you write a user preference into checkpointed state.
Two things follow from the analogy that people miss:
- Writing to the file is a deliberate act. A clinician decides a fact belongs in the permanent record. Nothing promotes appointment chatter to the file automatically, and LangGraph will not extract memories for you either —
store.put()is a call you make, wherever you decide it belongs. - A permanent record needs curation. Files go stale; conditions resolve; a note from 2019 can actively mislead. A store has no opinion about any of this and will serve a wrong fact as confidently as a right one.
Diagram
CHECKPOINTER — one thread's working state STORE — facts across threads
═══════════════════════════════════════ ═══════════════════════════════
thread "conv-1" (ana) namespace ("ana","preferences")
┌────────────────────────────┐ ┌────────────────────────────┐
│ ckpt#0 {request:"LHR ..."} │ ──put(seat)──────▶ │ seat : {"value":"aisle"} │
│ ckpt#1 {booked:"LHR ..."} │ │ │
└────────────────────────────┘ │ │
│ thread ends. state stays here, │ │
│ invisible to every other thread. │ │
▼ │ │
(unreachable from conv-2) │ │
│ │
thread "conv-2" (ana) — NEW, empty │ │
┌────────────────────────────┐ │ │
│ ckpt#0 {request:"JFK ..."} │ ◀──get(seat)────── │ ✓ still here │
│ seat=aisle ✓ │ │ │
└────────────────────────────┘ │ │
│ │
thread "conv-4" (ana) │ │
┌────────────────────────────┐ ──put(meal)──────▶ │ meal : {"value":"veg"} │
│ seat=aisle meal=veg ✓ │ └────────────────────────────┘
└────────────────────────────┘
thread "conv-3" (bo) — different user namespace ("bo","preferences")
┌────────────────────────────┐ ┌────────────────────────────┐
│ seat=unset meal=unset │ ◀──get(seat)────── │ (empty) │
└────────────────────────────┘ └────────────────────────────┘
▲
the namespace tuple is the isolation boundary ───┘
How it works (deep)
1. Namespaces are tuples, and the tuple is your security model
store.put(("ana", "preferences"), "seat", {"value": "aisle"})
item = store.get(("ana", "preferences"), "seat")
item.value # {"value": "aisle"}
Three positional arguments: the namespace (a tuple), the key (a string), and the value (a JSON-serialisable dict). The namespace behaves like a directory path, and search operates within one:
store.search(("ana", "preferences")) # every item in that namespace
The design decision is what goes in the tuple, and it is an isolation boundary rather than an organisational nicety. Leading with the user id — (user_id, "preferences") — means a lookup physically cannot reach another user's data. Organising by category first — ("preferences", user_id) — technically stores the same facts, but any code that searches ("preferences",) broadly now spans every user, and one such query in a prompt-building path is a cross-tenant leak.
Useful namespace shapes:
(user_id, "preferences") # per-user settings
(org_id, user_id, "memories") # multi-tenant, isolated at two levels
(user_id, "facts", "dietary") # sub-categorised
Validate user_id server-side before using it in a namespace. A namespace assembled from unchecked client input is a lookup on data the caller may not own.
2. Injection: declare the parameter, get the store
Compile with a store and nodes can ask for it:
app = graph.compile(checkpointer=InMemorySaver(), store=store)
def remember(state: BookingState, *, store: BaseStore) -> dict:
store.put((state["user_id"], "preferences"), "seat", {"value": "aisle"})
return {}
The store: BaseStore keyword parameter is populated by the runtime — you do not thread it through state or reach for a global. A node can also return {} and still be useful, as remember does: its entire job is a side effect on the store, and it contributes nothing to graph state. That is a legitimate and slightly unusual node shape.
3. Why checkpointed state cannot substitute
State lives inside a thread. A new thread_id starts from whatever you pass to invoke, so a preference written into state in conversation 1 is unreachable in conversation 2 — not deleted, just not looked at. You could theoretically read the old thread's final state and copy fields forward, and people do try this. It fails as a design because you must know which prior thread to read, you must merge conflicting values across many threads yourself, and thread state carries the whole conversation rather than the few durable facts.
The dividing question is one you can ask of any fact: should this survive the conversation ending? Message history, tool results, the current draft — no, those are thread state. Preferences, learned corrections, standing constraints — yes, those are store items.
4. Semantic search, and when you actually need it
A store can be configured with an embedding function, after which search accepts a natural-language query and ranks by similarity:
store = InMemoryStore(index={"embed": embedding_fn, "dims": 1536})
store.search((user_id, "memories"), query="dietary restrictions", limit=5)
This matters when memories are free-form sentences and you cannot predict the key. It is unnecessary — and a net loss — when memories are structured under known keys, as in the example below: get(ns, "seat") is exact, free, and cannot retrieve the wrong item, whereas a similarity search can return a plausible near-miss. Reach for embeddings when you are storing prose, not when you are storing fields.
5. Store backends and durability
InMemoryStore dies with the process — fine for tests, useless for the actual purpose, since a long-term memory that does not survive a deploy is not long-term. PostgresStore is the production choice and supports vector indexes for the semantic case. As with checkpointers, the interface is identical and only durability differs, so the swap is a one-line change you should make before shipping rather than after.
6. Time travel does not rewind the store
Worth stating explicitly because it surprises people who know both features. Forking a thread to an earlier checkpoint rewinds state, not store writes. A node that called store.put() before the fork point has already committed that fact, and replaying the node calls put() again. So store writes behave like any other external side effect under replay: they are at-least-once, and they are not undone by rewinding a thread. Key them so a repeat is harmless.
7. Where this stops being enough
A store is storage. It has no view on which statements deserve to be remembered, how to reconcile a new fact that contradicts an old one, when something has expired, or how to forget. Every one of those is application logic you write. Getting the mechanics right and the curation wrong produces an assistant that is confidently, durably wrong — which users find considerably worse than one that simply forgets.
The math
The case for a store is a token-cost argument: durable facts are small, and re-reading history to rediscover them is not.
C = conversations for one user
m = messages per conversation
t_m = tokens per message
F = tokens to represent the user's durable facts (small, roughly constant)
p = price per 1K input tokens
Option A — replay prior conversations to recover context:
tokens per new conversation T_A = F_implicit + (C - 1) * m * t_m GROWS with C
Option B — read facts from a store:
tokens per new conversation T_B = F CONSTANT in C
break-even at C = 1 + F / (m * t_m); beyond that the store wins and keeps winning.
Retrieval precision matters too. With k items in a namespace and a limit of l on an exact-key lookup, precision is 1 by construction. Under semantic search it is whatever your embedding gives you — call it q < 1 — so a wrong-but-similar memory is retrieved with probability 1 - q on every turn, forever.
Worked example
A user with C = 12 past conversations, m = 20 messages of t_m = 80 tokens, durable facts F = 150 tokens, at p = $0.003 per 1K input tokens.
T_A = 11 * 20 * 80 = 17,600 tokens -> 17.6 * 0.003 = $0.053 per turn
T_B = 150 tokens = 150 tokens -> 0.15 * 0.003 = $0.00045 per turn
ratio 17,600 / 150 = 117x fewer tokens
break-even C = 1 + 150/(20*80) = 1.09 -> the store wins from the SECOND conversation
The break-even at C ≈ 1.1 is the striking number: there is essentially no regime where replaying history to recover a handful of facts is the better choice. And T_A grows every conversation while T_B does not, so the gap widens indefinitely.
The precision figure cuts the other way and is the reason to prefer exact keys. With q = 0.9 semantic retrieval and 5 memories injected per turn, you are placing roughly one wrong memory into every other prompt — which is exactly how an assistant develops confident false beliefs about someone.
Real code
A booking assistant. Preferences learned in one conversation are used in later, entirely separate ones — and never cross between users.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.checkpoint.memory import InMemorySaver
class BookingState(TypedDict):
user_id: str
request: str
booked: str
def remember(state: BookingState, *, store: BaseStore) -> dict:
# Namespaces are tuples, so memory is scoped per user, not per conversation.
ns = (state["user_id"], "preferences")
if "aisle" in state["request"]:
store.put(ns, "seat", {"value": "aisle"})
if "vegetarian" in state["request"]:
store.put(ns, "meal", {"value": "vegetarian"})
return {}
def book(state: BookingState, *, store: BaseStore) -> dict:
ns = (state["user_id"], "preferences")
seat = store.get(ns, "seat")
meal = store.get(ns, "meal")
parts = [state["request"].split()[0]]
parts.append(f'seat={seat.value["value"]}' if seat else "seat=unset")
parts.append(f'meal={meal.value["value"]}' if meal else "meal=unset")
return {"booked": " ".join(parts)}
g = StateGraph(BookingState)
g.add_node("remember", remember); g.add_node("book", book)
g.add_edge(START, "remember"); g.add_edge("remember", "book"); g.add_edge("book", END)
store = InMemoryStore()
app = g.compile(checkpointer=InMemorySaver(), store=store)
def run(thread: str, user: str, request: str) -> str:
cfg = {"configurable": {"thread_id": thread}}
return app.invoke({"user_id": user, "request": request, "booked": ""}, cfg)["booked"]
# Conversation 1 teaches the assistant a preference.
print("conv-1 (ana):", run("conv-1", "ana", "LHR aisle please"))
# Conversation 2 is a DIFFERENT thread: checkpointed state is gone, the store is not.
print("conv-2 (ana):", run("conv-2", "ana", "JFK booking"))
# A different user shares nothing.
print("conv-3 (bo) :", run("conv-3", "bo", "JFK booking"))
# Adding a second preference later accumulates.
print("conv-4 (ana):", run("conv-4", "ana", "CDG vegetarian meal"))
print("--- what ana's namespace holds ---")
for item in store.search(("ana", "preferences")):
print(f" {item.key}: {item.value}")
assert "seat=aisle" in run("conv-5", "ana", "SFO booking"), "preference crossed threads"
assert "seat=unset" in run("conv-6", "bo", "SFO booking"), "users stay isolated"
assert len(store.search(("ana", "preferences"))) == 2
assert len(store.search(("bo", "preferences"))) == 0
print("OK: store outlived every thread; checkpointer state did not")
# Output:
# conv-1 (ana): LHR seat=aisle meal=unset
# conv-2 (ana): JFK seat=aisle meal=unset
# conv-3 (bo) : JFK seat=unset meal=unset
# conv-4 (ana): CDG seat=aisle meal=vegetarian
# --- what ana's namespace holds ---
# seat: {'value': 'aisle'}
# meal: {'value': 'vegetarian'}
# OK: store outlived every thread; checkpointer state did not
The four output lines are the entire argument. conv-2 is a brand-new thread_id whose state contains only "JFK booking" — no mention of seating anywhere — and it still books an aisle seat, because book read the store rather than the state. conv-3 is the control: same code, same graph, same store object, and bo gets seat=unset, because the namespace tuple made ana's data unreachable. conv-4 shows accumulation, with the earlier seat intact alongside the new meal.
Note that remember returns {}. It contributes nothing to graph state and exists purely for its effect on the store — an unusual-looking node that is exactly right here.
Real-world example
A clinical search tool serves specialists who each work in a narrow area, and every one of them was re-stating the same context at the start of every session: which evidence levels they consider acceptable, which journals they distrust, that they want paediatric studies excluded.
The first attempt stored those in graph state, and it worked beautifully during testing because testers stayed in one conversation. In production a clinician's second session started from an empty thread and knew nothing, so they re-typed their constraints daily and reasonably concluded the tool had no memory at all. Nothing was broken; the facts were in old checkpoints, which nothing consults.
Moving them to (clinician_id, "search_preferences") fixed it in an afternoon. The interesting part was what went wrong next.
The team enabled semantic search over free-text memories, storing whole sentences a clinician had said and retrieving the top 5 by similarity per query. It demoed well and degraded quietly. A clinician who once said "for this particular case, ignore the usual evidence threshold" had that stored as a durable memory, and it began surfacing on unrelated queries months later, lowering the evidence bar on questions where it should not have applied. Nobody noticed for weeks, because the output was plausible — just built on a weaker evidence base than the clinician believed they had asked for.
Two changes fixed it, and neither was about the store's mechanics. Memories became structured under known keys — evidence_floor, excluded_populations — read by exact get rather than similarity, so retrieving the wrong one stopped being possible. And anything captured from a single utterance got a review step: the tool proposed "should I remember this preference?" and only stored it on confirmation. That turned an accumulating pile of overheard sentences into a small curated set the clinician could inspect and edit.
The lesson generalises. The store did its job perfectly throughout — it remembered exactly what it was told, durably. The failure was entirely in deciding what deserved remembering and for how long, which no framework decides for you.
Interview questions companies actually ask
1. What is the difference between a checkpointer and a store? [easy]
A checkpointer persists one thread's working state, keyed by thread_id; a store persists facts across all threads, keyed by namespace and key. Different lifetimes and different scopes. "The assistant forgets my preferences between conversations" is nearly always a preference written into checkpointed state, where a new thread never looks.
2. Why can't you just read the previous thread's final state? [medium] You would need to know which prior thread to read, merge conflicting values across many threads yourself, and pull in whole conversations to extract a few facts. It also costs tokens that grow with conversation count, whereas store lookups are constant. The break-even is around 1.1 conversations — there is essentially no regime where it wins.
3. How does a node get access to the store? [easy]
Declare a keyword parameter store: BaseStore and the runtime injects it, provided the graph was compiled with store=.... You do not put it in state or use a global. Such a node may legitimately return {} if its only purpose is a store write.
4. Why is the namespace a tuple, and why does the order matter? [hard]
It behaves like a directory path that search operates within, so ordering determines what a broad query can reach. (user_id, "preferences") makes cross-user access physically impossible; ("preferences", user_id) stores the same facts but lets a search over ("preferences",) span every user — a cross-tenant leak waiting for one careless query. Validate any id server-side before putting it in a namespace.
5. When should you use semantic search over a store instead of exact keys? [hard]
Only when memories are free-form prose whose keys you cannot predict. For structured facts under known keys, exact get has precision 1 by construction and costs nothing, while similarity search can return a plausible near-miss. At 90% precision with 5 memories per turn you inject roughly one wrong memory every other prompt, which is how an assistant acquires durable false beliefs.
6. Does forking a thread with time travel undo store writes? [hard]
No. Time travel rewinds thread state, not external side effects, and a store.put() before the fork point has already committed. Replaying the node calls put() again, so store writes are at-least-once under replay and must be keyed so a repeat is harmless.
7. What does a store not do for you? [medium] Decide what to remember, reconcile a new fact with a contradicting old one, expire anything, or forget. It is durable storage with no judgement, so it will serve a stale or sarcastic one-off statement as confidently as a real standing preference. Curation is entirely application logic.
8. Which store backend for production, and why does it matter? [easy]
PostgresStore — InMemoryStore dies with the process, and a long-term memory that does not survive a deploy is not long-term. The interface is the same, so it is a one-line swap that should happen before shipping, and it is also what supports vector indexes if you need the semantic path.
9. How do you keep a store from accumulating junk over years of use? [hard] Deliberately: confirm before storing anything inferred from a single utterance, prefer structured keys so a new value overwrites rather than piles up alongside, attach timestamps and expire time-bounded facts, and give users a way to inspect and delete. None of this is provided — an append-forever store of overheard sentences degrades into a source of confident errors.
When to use / tradeoffs
Reach for a Store when:
- A fact should survive the conversation ending — preferences, standing constraints, learned corrections.
- Users return across many sessions and should not repeat themselves.
- Several threads or agents need the same knowledge about one subject.
- You need per-user or per-tenant isolation enforced by structure rather than by convention.
Do NOT use when:
| Situation | Why it breaks | Use instead |
|---|---|---|
| Message history, current draft, tool results | Thread-scoped working data; a store makes it global | Checkpointed state |
| Truly transient scratch values | Adds a durable write for something with no future | Plain state |
Production, with InMemoryStore | Memory dies on deploy; not long-term at all | PostgresStore |
| Structured facts, retrieved by similarity | Plausible near-misses become durable false beliefs | Exact get on known keys |
| Anything inferred from one offhand remark, stored unconfirmed | Accumulates wrong "preferences" that never expire | Confirm before writing |
| Namespace built from unvalidated client input | Caller can address another tenant's data | Validate server-side first |
Honest limits. The token arithmetic is favourable enough to be slightly misleading: it compares a store against the worst alternative (replaying whole conversations) and ignores that injected memories consume context and steer the model on every turn, including the many where they are irrelevant. A store therefore trades a growing cost for a permanent small one plus a permanent small risk, and the risk is the part that has no formula. Precision q is not a property you can measure once and trust, because the memory set grows and shifts under you, so a retrieval quality that was fine at 10 memories degrades at 200 with nothing announcing it. The deeper limit is that durability and correctness pull in opposite directions here: the same property that makes a store useful — it never forgets — is what makes a bad memory permanent, and the framework provides no expiry, no contradiction detection, and no forgetting. Every production memory system needs a curation policy, a way for users to see and delete what is held about them, and the recognition that a store inherits every data-retention obligation attached to what you put in it.
Summary + related articles
- Checkpointer = one thread's state. Store = facts across threads. Conflating them is the standard architectural bug.
store.put(namespace_tuple, key, dict)/get/search; nodes receive it via astore: BaseStoreparameter.- The namespace tuple is an isolation boundary — lead with the user or tenant id, and validate that id server-side.
- Ask of every fact: should this survive the conversation ending? That answer picks the mechanism.
- Prefer exact keys over semantic search unless memories are genuinely free-form prose.
- Time travel does not rewind store writes; they are at-least-once under replay.
InMemoryStoreis for tests;PostgresStorefor production.- A store supplies durability, never judgement — what to remember, reconcile, and expire is all yours.
Related articles in this module (6-6):
- Checkpointers, Threads, and Durable Execution — the thread-scoped counterpart, and why its state cannot serve this purpose.
- Time Travel and State History — why rewinding a thread leaves store writes in place.
- Human-in-the-Loop with interrupt() — the natural way to confirm a memory before storing it.
- Subgraphs and Graph Composition — sharing one store across composed graphs.
Related elsewhere:
- Memory Management — framework-agnostic short-term strategies: sliding windows, summarisation, TTL eviction.
- Consistency Memory: Making Repeated AI Judgments Agree With Each Other — storing a judgement so repeated decisions agree instead of being re-derived.
- LangGraph — the state model a store sits alongside.
- Vector Databases: Dimensions, Footprint, and the Migration Nobody Plans For — retrieval over a corpus, versus retrieval over remembered facts about a person.
Sources:
LangGraph docs: memory concepts ·
LangGraph docs: persistence and stores ·
LangGraph reference: BaseStore ·
Verified against langgraph 1.2.8; all output above is from an actual run.