skip to content

In LangGraph, when do you need a BaseStore instead of a checkpointer?

level: seniorimportance: should knowfreq 40%

answer

  1. one is sealed per conversation
  2. the other crosses conversations
  3. tuple namespaces choose the sharing boundary
  4. injected into nodes at compile time
  5. supports lookup by meaning when indexed

basics

~20 s

A checkpointer persists one thread's state and history, and nothing crosses thread boundaries. A BaseStore is namespaced key-value storage shared across threads, so facts a user should keep between separate conversations belong there, not in checkpointed state.

solid answer

~50 s

The checkpointer is thread-scoped by design: everything it writes is keyed by `thread_id`, so a fact learned in conversation A is invisible in conversation B. When you need data that outlives a single thread — user preferences, an account profile, accumulated facts about a customer — you attach a store: `builder.compile(checkpointer=..., store=...)`. A node then declares a `store: BaseStore` parameter and LangGraph injects it. The API is namespaced: `store.put(namespace, key, value)` where namespace is a tuple such as `("users", user_id, "facts")`, plus `get` and `search`. Configured with an embedding index, `InMemoryStore` and its persistent counterparts also support semantic `search(namespace, query=...)`. The rule of thumb: if losing the value when the conversation ends would be correct, it belongs in checkpointed state; if the user would be annoyed you forgot it next week, it belongs in the store.

code

python · 22 lines
python
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from langgraph.store.base import BaseStore

class State(TypedDict):
    note: str

def remember(state: State, *, store: BaseStore):
    ns = ("users", "user-42", "facts")
    store.put(ns, "diet", {"text": "vegetarian"})
    return {"note": store.get(ns, "diet").value["text"]}

builder = StateGraph(State)
builder.add_node("remember", remember)
builder.add_edge(START, "remember")
builder.add_edge("remember", END)

graph = builder.compile(checkpointer=InMemorySaver(), store=InMemoryStore())
print(graph.invoke({"note": ""}, {"configurable": {"thread_id": "a"}}))
print(graph.invoke({"note": ""}, {"configurable": {"thread_id": "b"}}))

go deeper

for a junior

Know the boundary: the checkpointer remembers one conversation, and anything the system should recall in a different conversation needs the separate store attached at compile time.

for a middle

Explain the tuple-namespace put/get/search API, how a node receives the store by declaring a BaseStore parameter, and why state and store are versioned differently.

for a senior

Argue the tradeoff — why a permanent per-user thread degrades, when your own database is the better home for long-term data, and how you scope namespaces so one tenant cannot read another's memory.

for a principal

Own the memory architecture: what the product must remember, where it legally may live, retention and deletion of user memories, and whether an injected store or your existing data platform is the right long-term home.

## Two persistence layers, two scopes LangGraph deliberately separates *run state* from *long-term memory*, and the interview question is whether you know which is which. **The checkpointer** persists the graph's state channels, keyed by `thread_id`. It is short-term memory in the literal sense: complete for one thread, versioned across its super-steps, and hermetically sealed from every other thread. That isolation is the point — two users' conversations must not contaminate each other, and two sessions of the same user are separate runs with separate histories. **The store** is the escape hatch from that isolation. It is a namespaced key-value service, attached alongside the checkpointer, and nothing about it is tied to a thread. ## Wiring a store `graph = builder.compile(checkpointer=InMemorySaver(), store=InMemoryStore())` Access is by dependency injection: a node function that declares a `store: BaseStore` parameter receives the compiled store. The concrete implementations mirror the checkpointer family — an in-memory one from `langgraph.store.memory` for tests and local work, and database-backed ones for deployment. ## The API The surface is small and deliberately not a database: - `store.put(namespace, key, value)` — namespace is a **tuple** of strings, key is a string, value is a JSON-serialisable dict. - `store.get(namespace, key)` — returns the stored item or nothing. - `store.search(namespace, ...)` — lists or queries items in a namespace, with filters, and with semantic query support when the store is configured with an embedding index. The tuple namespace is how you model scope: `("users", user_id, "preferences")` and `("org", org_id, "policies")` are different spaces that never collide. Because *you* choose the namespace, you also choose the sharing boundary — per user, per organisation, per agent — which is why authorisation of what a namespace may contain is a real design concern rather than a framework default. ## Choosing between them Ask what should happen when the conversation ends. - The message list, the current plan, intermediate tool outputs, the retry counter: losing these when the thread ends is correct. **Checkpointed state.** - "This user prefers metric units," "this customer's plan is Enterprise," "we already answered this question for them last month": forgetting these is a product bug. **Store.** A second test is scope of reads. If exactly one thread will ever read the value, put it in state — it is simpler, it is already versioned, and it travels through your reducers. If some *other* thread must read it, only the store can deliver that. ## Why not just use one big state object People try to force cross-thread memory into checkpointed state, usually by giving every user a single permanent thread. It works until it does not: - The state grows without bound, and every super-step re-serialises the whole thing, so writes get slower with the conversation's age. - History becomes unusable — a year of interactions in one linear checkpoint chain is not something you can meaningfully rewind. - Concurrency breaks: two simultaneous sessions on one thread interleave super-steps over shared channels. - Selective recall is impossible. State is loaded wholesale; the store lets you fetch just the three relevant facts, semantically if you index them. ## Why not just use your own database You can, and plenty of teams do — a store is not privileged access to anything you could not reach with your own client inside a node. What the store buys is injection (no global client to thread through), a namespace convention that composes with multi-agent graphs, optional semantic search without building an embedding pipeline, and a uniform swap between the in-memory implementation for tests and a persistent one in production. What it costs is another abstraction between you and your data, and a key-value model that will not serve genuinely relational needs. If long-term memory in your product is really a customer record with joins and constraints, keep it in your own schema and read it from a node. ## In multi-agent graphs When several agents run as nodes or subgraphs, checkpointed state is what they share within the run, and the store is what they share across runs. Namespacing lets one agent write into a space another agent reads later, without either of them being in the same thread — which is often the cleanest way to let a background or scheduled agent hand findings to the interactive one. ## The one-line answer Checkpointer = this conversation, versioned and resumable. Store = everything the system should still know next time, addressed by namespace rather than by thread.

  • Why is giving every user one permanent thread a bad substitute for a store?
    Because checkpointed state is loaded and written whole. A thread that never ends grows until every super-step re-serialises a huge object, its checkpoint history becomes too long to rewind usefully, and two concurrent sessions on the same thread interleave over shared channels. You also lose selective recall — you cannot fetch just the relevant facts out of a monolithic state.
  • How does a node get hold of the store?
    By declaring it. Compile the graph with store=..., and a node function that takes a store parameter typed as BaseStore has it injected at call time. There is no global to import or thread through your own factory, which is what makes the same node code work against an in-memory store in tests and a persistent one in production.
  • What does the tuple namespace buy you over a flat key?
    It makes scope explicit and collision-free. ("users", user_id, "facts") and ("org", org_id, "policies") are separate spaces you can list or search independently, and the boundary you encode there is exactly the boundary you must authorise. It also composes in multi-agent graphs, where one agent writes into a namespace another reads later without sharing a thread.
  • Is a store a replacement for your application database?
    No. It is a namespaced key-value service with optional semantic search, convenient because it is injected into nodes and swaps cleanly between test and production implementations. Genuinely relational data with constraints and joins belongs in your own schema, read from inside a node with your normal client. Use the store for agent memory, not as a general data layer.

saying these in an interview costs you the question

  • Expecting a checkpointer to share state between different threads
  • Making one permanent thread per user as long-term memory
  • Thinking the store versions its values like checkpoints do
  • Assuming semantic search works without configuring an embedding index
  • Treating the store as a general-purpose relational database

context