# LangGraph memory explained: checkpointer vs store, and per-user memory across threads

*Disclosure: I'm Mohammed, founder of MemorySync. I drafted this article with AI assistance and reviewed it for accuracy against the official MemorySync and LangGraph docs.*

If your LangGraph agent forgets everything the moment you start a new conversation, the graph is usually doing exactly what it was told. A checkpointer remembers a *thread*. It was never meant to remember a *user*. This post explains the split between the checkpointer and the store, then adds per-user memory that works across threads with MemorySync's LangGraph integration.

The LangGraph behaviour below comes from LangGraph's [persistence docs](https://docs.langchain.com/oss/python/langgraph/persistence) and [memory guide](https://docs.langchain.com/oss/python/langgraph/add-memory). Everything about MemorySync comes from its [LangGraph Memory guide](https://docs.memorysync.io/guides/langgraph?utm_source=hashnode&utm_medium=social&utm_campaign=pivot_oct19&utm_content=langgraph-checkpointer-vs-store-cross-thread-memory). The code is adapted from those docs. I haven't run it for this post, so check it against your versions.

## Why the agent "forgets"

LangGraph has two persistence pieces, and they solve different problems:

| | Checkpointer | Store |
|---|---|---|
| Memory type | Short-term, scoped to one thread | Long-term, shared across threads |
| How you reach it | Pass a `thread_id` in the graph config | Read and write items from nodes or app code |
| Typical contents | The conversation so far, interrupts, time travel | User preferences, facts, shared knowledge |

So two things commonly go wrong:

1. **A new `thread_id` means a fresh state.** The checkpointer brings back the history of `thread-1` when you call `thread-1` again. Call `thread-2` and it starts empty. That's by design.
2. **`MemorySaver` / `InMemorySaver` live in RAM.** LangGraph's docs say it directly: when the process restarts, every checkpoint is gone. For production they point you to a persistent checkpointer such as `PostgresSaver`.

Fixing (2) gets you threads that survive restarts. It doesn't fix (1). For "remember this user in every conversation" you need a store, and the store you get out of the box, `InMemoryStore`, also lives in memory.

The usual setup compiles the graph with both:

```python
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

graph = builder.compile(checkpointer=InMemorySaver(), store=InMemoryStore())
```

Keep the checkpointer. The rest of this post swaps out the store and adds per-user recall.

## Where MemorySync fits

MemorySync's guide divides the work the same way: thread state (checkpoints, resume, time travel) stays with a LangGraph checkpointer like `langgraph-checkpoint-postgres`, `-sqlite` or `-redis`. Long-term memory across threads goes to MemorySync. The integration has five pieces:

- `MemorySyncStore`: a LangGraph `BaseStore`, passed in with `store=`.
- `MemorySyncMemoryMiddleware`: injects context before the model call for `create_agent` (langchain 1.x).
- `create_memorysync_pre_model_hook`: the same injection for `create_react_agent`.
- `create_persist_turn_node`: a graph node that sends the latest user message to MemorySync, where durable facts are extracted.
- `create_memory_search_tool`: a memory search tool the graph can call.

## Step 1: Install

```bash
pip install "langchain-memorysync[langgraph]"
```

Set `MEMORYSYNC_API_KEY` in your environment, or pass the key in explicitly. The guide lists langgraph 1.2+ and Python 3.10+. On Node.js the same exports ship in `memorysync-langchain` under the `/langgraph` subpath, which needs `@langchain/langgraph` 0.2+ and Node 18+. The examples here are Python.

## Step 2: Swap the store and namespace it per user

Namespaces are string tuples, the same as in LangGraph. Put the user id in the namespace:

```python
from langchain_memorysync.langgraph import MemorySyncStore

store = MemorySyncStore()  # reads 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)
```

According to the docs, each namespace maps to its own server-side scope, so `("memories", "user-a")` and `("memories", "user-b")` stay separate on the server rather than through a client-side filter. Two more things the guide notes:

- **No embedding config.** LangGraph stores usually need an index config with an embedder and dimensions. MemorySync embeds stored values server-side, so `search` works without one.
- **Retries don't duplicate.** LangGraph re-runs nodes after interrupts and errors. A key holds one live value: an identical retry is recognised server-side, and a changed value replaces the old one.

Store values are treated as framework state. They don't show up in MemorySync's memory list, search or recall.

## Step 3: Compile it into an agent

Tools reach the store through `get_store()`:

```python
from langchain.agents import create_agent
from langchain_core.tools import tool
from langgraph.config import get_store
from langchain_memorysync.langgraph import MemorySyncStore

@tool
def save_preference(key: str, value: str) -> str:
    """Save a user preference to long-term memory."""
    get_store().put(("memories", "user-1"), key, {"text": value})
    return f"Saved {key}."

@tool
def recall_preferences(query: str) -> str:
    """Search the user's long-term memory."""
    hits = get_store().search(("memories", "user-1"), query=query)
    return "\n".join(h.value["text"] for h in hits) or "Nothing stored yet."

agent = create_agent(
    model,
    tools=[save_preference, recall_preferences],
    store=MemorySyncStore(),
)
```

In a real app, replace the hard-coded `"user-1"` with your own stable user id.

## Step 4: Recall per user without tool calls

Explicit save and recall tools only work when the model decides to call them. If you want the user's context to be there every turn, inject it before the model call. Use the middleware for `create_agent` or the pre-model hook for `create_react_agent`:

```python
# create_agent (langchain 1.x)
from langchain.agents import create_agent
from langchain_memorysync.langgraph import MemorySyncMemoryMiddleware

agent = create_agent(
    model,
    tools=tools,
    middleware=[MemorySyncMemoryMiddleware(user_id="customer-7")],
)

# create_react_agent
from langgraph.prebuilt import create_react_agent
from langchain_memorysync.langgraph import create_memorysync_pre_model_hook

agent = create_react_agent(
    model,
    tools,
    pre_model_hook=create_memorysync_pre_model_hook(user_id="customer-7"),
)
```

The docs say both only change what the model sees for that one call. Your checkpointed state isn't modified, so the stored transcript stays exactly what was said. If nothing relevant is found, the input passes through unchanged. Auto-persistence on the middleware is **off** by default.

## Step 5: Save what the user tells you

To write facts from the conversation, add the persist node after your model node:

```python
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_memorysync.langgraph import create_persist_turn_node

remember = create_persist_turn_node(user_id="customer-7")

graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("remember", remember)
graph.add_edge(START, "agent")
graph.add_edge("agent", "remember")
graph.add_edge("remember", END)
app = graph.compile()

app.invoke({"messages": [("user", "I always fly aisle.")]},
           config={"configurable": {"thread_id": "trip-1"}})
```

The session defaults to the graph's `thread_id`, so facts are grouped by conversation without extra wiring. If you're on `create_agent`, there's another option: `MemorySyncMemoryMiddleware(user_id=..., auto_persist=True)`.

## Step 6: Check it across two threads

This is the test the original question needed: two different `thread_id`s for the same user. It's adapted from the guide's complete example:

```python
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_memorysync.langgraph import (
    MemorySyncMemoryMiddleware,
    MemorySyncStore,
    create_memory_search_tool,
)

USER = "customer-7"

agent = create_agent(
    ChatOpenAI(model="your-model"),
    tools=[create_memory_search_tool(user_id=USER)],
    middleware=[MemorySyncMemoryMiddleware(user_id=USER, auto_persist=True)],
    store=MemorySyncStore(),
)

agent.invoke(
    {"messages": [{"role": "user", "content": "I'm vegetarian and I fly aisle."}]},
    config={"configurable": {"thread_id": "monday"}},
)

out = agent.invoke(
    {"messages": [{"role": "user", "content": "Book my trip: flight plus a dinner spot."}]},
    config={"configurable": {"thread_id": "friday"}},
)
print(out["messages"][-1].content)
```

The `friday` thread has no checkpoint history from `monday`. Any recall of the diet or seat preference has to come from the per-user memory, and the guide says that holds even if you stop the process between the two calls. If the second answer knows neither fact, look at `user_id` first. It has to be the same value in both calls.

The search tool is read-only and scoped to one end user. It returns readable strings instead of raising, because a tool exception would abort the whole graph run.

## Recap

- Checkpointer = this thread. Keep it, and use a persistent one (Postgres, SQLite, Redis) in production.
- Store = across threads. Namespace it by user.
- To make the agent remember the user every turn, inject context (middleware or pre-model hook) and persist facts (persist node or `auto_persist=True`).

## Try it

If you want to see the save, reload and recall loop before wiring it into a graph, the MemorySync playground runs it in your browser: [memorysync.io/try](https://memorysync.io/try?utm_source=hashnode&utm_medium=social&utm_campaign=pivot_oct19&utm_content=langgraph-checkpointer-vs-store-cross-thread-memory).

