← Back to Learning Hub

Long-Term Memory with Store

LangGraphDurable ExecutionAdvanced20 min

By: Anacodic Team

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] PostgresStoreInMemoryStore 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:

SituationWhy it breaksUse instead
Message history, current draft, tool resultsThread-scoped working data; a store makes it globalCheckpointed state
Truly transient scratch valuesAdds a durable write for something with no futurePlain state
Production, with InMemoryStoreMemory dies on deploy; not long-term at allPostgresStore
Structured facts, retrieved by similarityPlausible near-misses become durable false beliefsExact get on known keys
Anything inferred from one offhand remark, stored unconfirmedAccumulates wrong "preferences" that never expireConfirm before writing
Namespace built from unvalidated client inputCaller can address another tenant's dataValidate 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.


  • 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 a store: BaseStore parameter.
  • 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.
  • InMemoryStore is for tests; PostgresStore for production.
  • A store supplies durability, never judgement — what to remember, reconcile, and expire is all yours.

Related articles in this module (6-6):

Related elsewhere:

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.