Long-Term Memory Stores
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12,
langgraph-checkpoint-postgres 3.1.2 and langchain-ollama 1.1.0 on Python 3.12, as verified on 2026-09-29. The
running example is DocsAssistant, which answers questions over this site’s documentation using Ollama’s
llama3.1:8b chat model and nomic-embed-text embedding model over the pgvector store built in
Integrating with LangChain. Ollama was not available while
the snippets were checked, so every output shown here was produced with a scripted fake chat model
(GenericFakeChatModel) and, where a snippet needs embeddings, a toy keyword-count function in place of
nomic-embed-text; the similarity scores shown are therefore illustrative, and no output comes from a live model.
The PostgresStore snippets are the exception on the storage side: they ran against a real PostgreSQL 17.11 server
with pgvector 0.8.6 started in a throw-away container.
An agent with only a checkpointer remembers a conversation and nothing else: start a new thread and it has forgotten
that Alice wants Java examples. Long-term memory is the other half. It stores facts, preferences and past episodes
outside any thread, so the next conversation, the next week’s conversation, or a different agent altogether can
read them. In LangGraph this is the job of a store, and in LangChain 1.x an agent gets one by passing it to
create_agent. This page covers the store API, the two implementations most projects use, how to organise memories
per user, how to search them by meaning, how an agent writes them, and how to keep the whole thing private and
bounded. The taxonomy of memory types (semantic, episodic, procedural), extraction and compression, and the
comparison with LangChain4j and Spring AI live in Memory; the vector-store side of the
same idea, with a conversation-search schema and semantic caching, is in
Agent Memory & Semantic Cache. Thread-scoped memory is
covered in Short-Term Memory & Checkpointers.
Threads versus stores
A checkpointer saves the state of one thread after every step, keyed by thread_id. A store is a separate
key-value database that any thread can read and write, organised by namespace and key. The two are independent
and are usually used together:
Two threads that belong to Alice see the same memories because they read the same namespace; Bob’s thread cannot see them because the application derives the namespace from who is asking, not from anything the model says. That is the design rule the rest of the page builds on.
Each memory is a JSON document (a Python dict) with a key, stored under a namespace (a tuple of strings,
like a folder path). The store adds created_at and updated_at timestamps. LangChain’s long-term-memory page
describes the same model: "LangGraph stores long-term memories as JSON documents in a store", each under a custom
namespace and a distinct key. See LangChain — Long-term memory and LangGraph — Stores.
The BaseStore API
Every store implements BaseStore from langgraph.store.base. Its core methods are put, get, delete,
search and list_namespaces, each with an a-prefixed async twin (aput, aget, adelete, asearch,
alist_namespaces). InMemoryStore keeps everything in a dictionary, which is right for tests and development.
This snippet exercises the whole surface:
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
alice = ("docsassistant", "alice", "preference")
store.put(alice, "language", {"text": "Answer with Java examples only", "source": "chat"})
store.put(("docsassistant", "alice", "episode"), "e1", {"text": "Asked how to tune hnsw ef_search"})
store.put(("docsassistant", "bob", "preference"), "language", {"text": "Answer with Python examples"})
item = store.get(alice, "language") # one item by exact namespace + key
print(item.value, item.key, item.namespace)
print([i.key for i in store.search(("docsassistant", "alice"))]) # prefix match
print(store.list_namespaces(prefix=("docsassistant",), max_depth=2))
print(store.search(("docsassistant", "alice"), filter={"source": "chat"})[0].value)
store.delete(alice, "language")
print(store.get(alice, "language"))
print(store.search(("docsassistant", "bob"))[0].dict())
{'text': 'Answer with Java examples only', 'source': 'chat'} language ('docsassistant', 'alice', 'preference')
['language', 'e1']
[('docsassistant', 'alice'), ('docsassistant', 'bob')]
{'text': 'Answer with Java examples only', 'source': 'chat'}
None
{'namespace': ['docsassistant', 'bob', 'preference'], 'key': 'language', 'value': {'text': 'Answer with Python examples'}, 'created_at': '2026-09-29T13:17:04.136128+00:00', 'updated_at': '2026-09-29T13:17:04.136128+00:00', 'score': None}
Things to notice:
-
putwith an existing namespace and key overwrites that item, which is how a memory is updated in place. -
getreturns anItem(orNone);searchreturnsSearchItemobjects, which add ascorethat isNoneunless a semanticquerywas given. -
searchtakes a namespace prefix, not an exact namespace.("docsassistant", "alice")returns items under bothpreferenceandepisode; pass the full namespace to restrict to one level. Itsfilterargument matches on fields of the stored value (\{"source": "chat"}above), andlimit(default 10) andoffsetpage through results. The official page warns that results pastlimitare silently truncated, so set it above the expected maximum or page withoffset. -
list_namespacesdiscovers what exists (prefix,suffix,max_depth), for example to iterate over users. -
Result ordering is backend-dependent when no query is given: the LangGraph docs note that the Postgres stores order by
updated_atdescending, whileInMemoryStorereturns insertion order. Do not rely on either.
See LangGraph — Stores.
Namespaces per user
The namespace is the store’s only built-in access boundary, so its design is a security decision, not just
tidiness. A workable layout for DocsAssistant is ("docsassistant", <user_id>, <kind>): an application prefix
(so several apps can share one database), the user, and a memory kind such as preference or episode. Prefix
matching then gives both views for free: search ("docsassistant", "alice") for everything about Alice, or add the
kind to see only her preferences.
The user ID must come from authenticated application code, never from a tool argument the model fills in. In
create_agent the clean way is the run context: define a small dataclass, pass it as context_schema, supply the
value at invoke time, and read it in tools through ToolRuntime. The model can neither see nor change it. The
memory tools below all follow this pattern; the next section shows the failure it prevents.
The kind of memory also affects the shape of the values. The LangChain memory overview distinguishes a single,
continuously updated profile document (one key per user, overwritten on update) from a collection of many small
documents (one key per memory, searched at read time). Profiles are cheap to read but error-prone to rewrite as
they grow; collections give better recall but need search and clean-up. DocsAssistant uses a collection, because
"prefers Java" and "asked about ef_search on Tuesday" are separate facts that age at different rates. See
LangChain — Memory overview.
Semantic search in stores
By default search filters and pages; it does not understand meaning. To search by similarity, give the store an
index when you construct it. The index config has three parts: an embed function or embeddings object, the
vector size dims, and the fields of the stored value to embed (a JSON-path style list; "$" means the whole
document). Any object that turns strings into vectors works, including a plain function. This snippet uses a toy
keyword-count function so it runs without a model server:
import math
from langgraph.store.memory import InMemoryStore
VOCAB = ["java", "python", "index", "hnsw", "search", "prefer", "spring", "postgres"]
def embed(texts: list[str]) -> list[list[float]]:
"""Toy bag-of-keywords embedding so the demo runs without Ollama."""
vectors = []
for text in texts:
counts = [float(text.lower().count(word)) for word in VOCAB]
norm = math.sqrt(sum(c * c for c in counts)) or 1.0
vectors.append([c / norm for c in counts])
return vectors
store = InMemoryStore(index={"embed": embed, "dims": len(VOCAB), "fields": ["text"]})
ns = ("docsassistant", "alice", "preference")
store.put(ns, "a", {"text": "I prefer Java and Spring examples"})
store.put(ns, "b", {"text": "I use python notebooks"})
store.put(ns, "c", {"text": "Tune the hnsw index in postgres search"})
store.put(ns, "d", {"text": "internal java note"}, index=False) # stored, never embedded
for hit in store.search(ns, query="how do I tune the hnsw index?", limit=3):
print(hit.key, hit.score, hit.value["text"])
print([i.key for i in store.search(ns, filter={"text": "internal java note"})])
c 0.7071067811865476 Tune the hnsw index in postgres search
a 0.0 I prefer Java and Spring examples
b 0.0 I use python notebooks
['d']
Three details matter. The query argument turns search into a similarity search and fills in each result’s
score; filter can still be combined with it. A per-item index argument on put overrides fields: a list of
field names embeds only those, and index=False stores the item without embedding it, so it stays retrievable by
key or filter (d above) but never appears in semantic results. And the vector size must match the model exactly.
DocsAssistant would use the same nomic-embed-text model as its document index (768 dimensions), through
langchain-ollama. LangChain’s init_embeddings accepts a provider:model string, and the store takes the
resulting object in embed:
from langchain.embeddings import init_embeddings
from langgraph.store.memory import InMemoryStore
embeddings = init_embeddings("ollama:nomic-embed-text")
print(type(embeddings).__name__, embeddings.model)
store = InMemoryStore(index={"embed": embeddings, "dims": 768, "fields": ["text"]})
OllamaEmbeddings nomic-embed-text
Constructing the objects needs no server; the first put or search would call Ollama, which was not available
here. Use the same embedding model for indexing and querying, and pick memory text that reads like a statement
("Prefers Java examples") rather than a fragment, because the embedded text is what gets compared with the query.
Semantic memory in the psychological sense (facts about a user) is a different idea from semantic search
(retrieval by meaning); a store gives you both. See
LangGraph — Stores and
LangSmith — Semantic search.
PostgresStore for production
InMemoryStore loses everything when the process exits and cannot be shared between workers. For DocsAssistant,
whose vectors already live in PostgreSQL with pgvector, PostgresStore is the natural durable choice. It is
provided by the langgraph-checkpoint-postgres package, which the official page installs together with the
recommended Psycopg extra:
pip install -U langgraph-checkpoint-postgres "psycopg[binary]"
PostgresStore.from_conn_string opens a connection and is used as a context manager. The first run needs
store.setup(), which creates the tables. The connection string is read from an environment variable rather than
written in code. The same API and the same semantic index config as InMemoryStore then apply; this snippet ran
against a real PostgreSQL 17.11 / pgvector 0.8.6 server:
import math
import os
from langgraph.store.postgres import PostgresStore
DB_URI = os.environ["DOCSASSISTANT_DB_URI"] # e.g. a postgresql:// URL; never hard-code credentials
VOCAB = ["java", "python", "index", "hnsw", "search", "prefer", "spring", "postgres"]
def embed(texts):
vectors = []
for text in texts:
counts = [float(text.lower().count(word)) for word in VOCAB]
norm = math.sqrt(sum(c * c for c in counts)) or 1.0
vectors.append([c / norm for c in counts])
return vectors
with PostgresStore.from_conn_string(
DB_URI, index={"embed": embed, "dims": len(VOCAB), "fields": ["text"]}
) as store:
store.setup() # creates the tables; safe to call again
ns = ("docsassistant", "alice", "preference")
store.put(ns, "a", {"text": "I prefer Java and Spring examples"})
store.put(ns, "b", {"text": "I use python notebooks"})
store.put(ns, "c", {"text": "Tune the hnsw index in postgres search"})
print(store.get(ns, "a").value)
for hit in store.search(("docsassistant", "alice"), query="tune hnsw index", limit=2):
print(hit.key, round(hit.score, 3), hit.value["text"])
print(store.list_namespaces(prefix=("docsassistant",)))
store.delete(ns, "b")
print([i.key for i in store.search(ns)])
{'text': 'I prefer Java and Spring examples'}
c 0.707 Tune the hnsw index in postgres search
a 0.0 I prefer Java and Spring examples
[('docsassistant', 'alice', 'preference')]
['c', 'a']
After setup() the database contained the tables store and store_vectors (plus two migration bookkeeping
tables); the embeddings live in store_vectors using pgvector. Results without a query came back most recently
updated first (c, then a), matching the ordering the docs describe. Some practical points:
-
AsyncPostgresStore(same package,langgraph.store.postgres) is the async variant for async graphs and servers;from_conn_stringalso acceptspipelineandpool_configoptions for pipelining and connection pooling when a single connection is not enough. -
Point the store at the same PostgreSQL instance as the rest of DocsAssistant when you want one thing to back up and one thing to secure, but keep memories in their own tables: the store manages its own schema and migrations, which is unrelated to the
PGVectortables from Integrating with LangChain. -
Redis, MongoDB and other backends exist as integrations; the official page links the list. If you write your own, subclass
BaseStoreand implement the async methods; when the backend has no vector search, raiseNotImplementedErrorfor aquery. -
When the agent runs on the LangSmith Agent Server (see Deployment), the platform provides the store for you, and you configure it in
langgraph.json, as shown in the retention section below.
Giving an agent a store
To let an agent use the store, pass it to create_agent(…, store=store). Tools then reach it through
ToolRuntime, exactly as the state and context injection on
Tools works: a parameter typed ToolRuntime[YourContext] is hidden from the model’s
schema, and runtime.store and runtime.context are filled in at call time. The other half of the wiring is a
checkpointer for the thread’s own messages
(see Agents with create_agent).
Memory-writing tools
Long-term memory only helps if something writes it. The most direct pattern is the hot path: give the agent a tool to save a fact and a tool to look facts up, and let the model decide when to call them. The tools take the namespace from the run context, so the model supplies only the content:
from dataclasses import dataclass
from datetime import datetime, timezone
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
@dataclass
class UserContext:
user_id: str # set by the application after authentication
store = InMemoryStore(index={"embed": embed, "dims": len(VOCAB), "fields": ["text"]})
@tool
def remember(fact: str, kind: str, runtime: ToolRuntime[UserContext]) -> str:
"""Save one durable fact about this user. kind is "preference" or "episode"."""
namespace = ("docsassistant", runtime.context.user_id, kind)
key = datetime.now(timezone.utc).isoformat()
runtime.store.put(namespace, key, {"text": fact})
return f"saved as {kind}"
@tool
def recall(query: str, runtime: ToolRuntime[UserContext]) -> list[str]:
"""Search what you remember about this user."""
hits = runtime.store.search(("docsassistant", runtime.context.user_id), query=query, limit=3)
return [h.value["text"] for h in hits]
agent = create_agent(
model, # a scripted fake model in the run below
tools=[remember, recall],
context_schema=UserContext,
checkpointer=InMemorySaver(), # short-term: one thread's messages
store=store, # long-term: shared across threads
)
def ask(text, thread, user):
out = agent.invoke({"messages": [("user", text)]}, {"configurable": {"thread_id": thread}},
context=UserContext(user_id=user))
return out["messages"]
m = ask("I only use Spring AI, Java answers please.", "alice-1", "alice")
print(len(m), m[-1].content)
m = ask("What language do I prefer?", "alice-2", "alice") # new thread, same user
print([x.content for x in m if x.type == "tool"][0])
print(m[-1].content)
m = ask("What language do I prefer?", "bob-1", "bob") # different user
print([x.content for x in m if x.type == "tool"][0], "|", m[-1].content)
print(store.list_namespaces())
4 Noted, Java only from now on.
["Prefers Java only, spring examples"]
You prefer Java and Spring examples.
[] | I do not know your preferences yet.
[('docsassistant', 'alice', 'preference')]
The scripted model (a GenericFakeChatModel subclass whose bind_tools returns itself, fed AIMessage objects
with tool_calls) called remember on the first turn and recall on the next two; the point is what the store
did. Thread alice-2 starts with an empty message history yet recall found the preference, because the store is
shared across threads and the namespace is keyed by user. Bob’s thread ran the same recall and got an empty list,
because the user ID came from the context the application supplied. Had remember taken a user_id argument, a
prompt-injected model could have written to, or read from, any namespace it chose to name. This is the same
least-privilege reasoning that Tool Design applies to every stateful tool.
A tool that writes memory is a write action, and it deserves the controls other write actions get. Keep the payload small and typed (one fact and a kind, not a free-form dump), return a short confirmation, and think about whether some saves need human approval before they persist; the approval mechanism is covered in Human-in-the-Loop & Time Travel and the design rules in Human-in-the-Loop.
Reading and writing from graph nodes
Not every memory should depend on the model deciding to call a tool. Inside a LangGraph node, the same store and
the run context arrive through a Runtime parameter, which LangGraph injects when the node declares it. The
official memory overview names the two write timings: on the hot path (the agent saves as it goes, with
real-time updates but extra latency and extra work for the model) and in the background (a separate step or job
extracts memories from the conversation, at the cost of deciding when to run it). The graph below reads relevant
memories before the model call and then runs a write step after it. The extraction step is a stand-in
(a keyword test); in practice it would be an LLM call with structured output (see
Structured Output) that returns zero or more distilled facts:
import asyncio
import uuid
from dataclasses import dataclass
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.runtime import Runtime
from langgraph.store.memory import InMemoryStore
@dataclass
class Context:
user_id: str
model = GenericFakeChatModel(messages=iter([AIMessage("Here is a Java answer."), AIMessage("Java again.")]))
async def call_model(state: MessagesState, runtime: Runtime[Context]):
namespace = (runtime.context.user_id, "memories")
hits = await runtime.store.asearch(namespace, query=state["messages"][-1].content, limit=3)
known = "\n".join(h.value["text"] for h in hits)
system = f"Known about the user:\n{known}" if known else "Nothing known about the user."
print("system prompt ->", system.replace("\n", " | "))
reply = await model.ainvoke([("system", system), *state["messages"]])
return {"messages": [reply]}
async def write_memory(state: MessagesState, runtime: Runtime[Context]):
last_user = [m for m in state["messages"] if m.type == "human"][-1].content
if "prefer" in last_user.lower(): # stand-in for an LLM extraction step
await runtime.store.aput((runtime.context.user_id, "memories"), str(uuid.uuid4()), {"text": last_user})
builder = StateGraph(MessagesState, context_schema=Context)
builder.add_node(call_model)
builder.add_node(write_memory)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", "write_memory")
builder.add_edge("write_memory", END)
store = InMemoryStore(index={"embed": embed, "dims": len(VOCAB), "fields": ["text"]})
graph = builder.compile(checkpointer=InMemorySaver(), store=store)
async def main():
cfg1, cfg2 = {"configurable": {"thread_id": "1"}}, {"configurable": {"thread_id": "2"}}
await graph.ainvoke({"messages": [("user", "I prefer Java only, with Spring")]}, cfg1, context=Context("alice"))
await graph.ainvoke({"messages": [("user", "explain the hnsw index in Java")]}, cfg2, context=Context("alice"))
asyncio.run(main())
system prompt -> Nothing known about the user.
system prompt -> Known about the user: | I prefer Java only, with Spring
The second thread’s model call received the memory written during the first thread. The graph is compiled with both a checkpointer and a store, which is the pattern the official Stores page shows; the checkpointer keeps each thread separate while the store carries what should survive. Treat text read from the store as untrusted input when it goes into a prompt, for the reasons in the next section. For the graph API itself see LangGraph Fundamentals. See LangGraph — Stores.
Privacy and retention
A memory store is a database of personal data about your users, and it needs the same care as any other. The generic obligations are set out on Memory and in Agent Guardrails & Safety; this section shows how they map onto the LangGraph store.
-
Minimise. Store the distilled fact ("prefers Java examples"), not the transcript it came from, and never store secrets, credentials or special-category data. Validate what a memory tool accepts, and consider PII redaction before writes (see Guardrails & Security).
-
Isolate by user. Build the namespace from authenticated context, as above. The store has no per-namespace permission model of its own, so isolation is entirely the application’s responsibility.
-
Treat memories as untrusted input. A memory written from a prompt-injected conversation can steer every later one. Keep writes in an audit trail and prefer narrow, structured memories to arbitrary text that is replayed into the system prompt.
-
Expire what should not live forever. The store supports a time-to-live (TTL) per item.
-
Make deletion real. A "forget me" request must reach the store as well as the checkpointer, chat history and any other index.
Retention is configured through a TTL. default_ttl is a number of minutes, and items get it unless a put
overrides it with its own ttl (also in minutes; ttl=None means "never expires"). With omit_expired set, an
expired item stops appearing in reads immediately, before a sweep physically deletes it, and refresh_on_read
controls whether reading an item resets its timer. The snippet below ran against the same PostgreSQL server with a
three-second default so the effect is visible:
import os
import time
from langgraph.store.postgres import PostgresStore
DB_URI = os.environ["DOCSASSISTANT_DB_URI"]
ttl = {"default_ttl": 0.05, "refresh_on_read": False, "omit_expired": True} # 0.05 min = 3 s
with PostgresStore.from_conn_string(DB_URI, ttl=ttl) as store:
store.setup()
ns = ("docsassistant", "carol", "episode")
store.put(ns, "e1", {"text": "asked about ef_search"}) # inherits the 3 s default
store.put(ns, "e2", {"text": "kept"}, ttl=None) # never expires
print("before:", sorted(i.key for i in store.search(ns)))
time.sleep(4)
print("after :", sorted(i.key for i in store.search(ns))) # expired item is omitted
print("swept :", store.sweep_ttl()) # and later physically deleted
before: ['e1', 'e2']
after : ['e2']
swept : 1
sweep_ttl() deletes expired rows on demand, and the store can also run a periodic sweeper (TTLConfig has a
sweep_interval_minutes key; PostgresStore has start_ttl_sweeper / stop_ttl_sweeper). These names were
checked against the installed package; the official pages document the same settings for deployments. When the agent
runs on LangSmith’s Agent Server, you set them in langgraph.json under store.ttl, next to the embedding index
under store.index. The configured default_ttl applies only to items created after the change, so older items
must be deleted manually:
{
"dependencies": ["."],
"graphs": { "docsassistant": "./docsassistant/agent.py:agent" },
"store": {
"index": { "embed": "ollama:nomic-embed-text", "dims": 768, "fields": ["text"] },
"ttl": { "refresh_on_read": true, "sweep_interval_minutes": 120, "default_ttl": 43200 }
}
}
The JSON parses; it was not deployed here. Each deployment supports a single embedding model. Expiry handles the passage of time. A user’s request to be forgotten is different: it has to remove everything under the user’s namespace prefix, however old. Because the store has no "delete a namespace" call, delete item by item, paging until the search comes back empty:
from langgraph.store.base import BaseStore
def forget_user(store: BaseStore, user_id: str, page_size: int = 100) -> int:
"""Delete every item under ("docsassistant", user_id, ...) and return how many were removed."""
removed = 0
while items := store.search(("docsassistant", user_id), limit=page_size):
for item in items:
store.delete(item.namespace, item.key)
removed += 1
return removed
store.put(("docsassistant", "alice", "preference"), "language", {"text": "Java only"})
store.put(("docsassistant", "alice", "episode"), "e1", {"text": "asked about ef_search"})
store.put(("docsassistant", "bob", "preference"), "language", {"text": "Python"})
print("removed:", forget_user(store, "alice"))
print("alice:", store.search(("docsassistant", "alice")))
print("bob:", [i.key for i in store.search(("docsassistant", "bob"))])
removed: 2
alice: []
bob: ['language']
Because every memory of Alice’s sits under one prefix, deletion is a single loop; a namespace layout that scattered her data across unrelated prefixes would have made it a search problem. Also delete her threads from the checkpointer (see Short-Term Memory & Checkpointers) and any other stores that hold copies. Broader guidance on data protection for AI systems belongs to the planned AI Security sub-section. See LangSmith — Configure TTL.
Where to go next
The table summarises the pieces and where each was shown:
| Need | LangChain / LangGraph tool | Shown in |
|---|---|---|
Development store |
|
BaseStore operations |
Durable, shared store |
|
PostgresStore for production |
Per-user isolation |
Namespace from run |
Namespaces per user |
Find memories by meaning |
|
Semantic search in stores |
Save and look up memories |
|
Memory-writing tools |
Expire and forget |
|
Privacy and retention |
Memory that the agent reads is context the agent is given, so what you retrieve competes with everything else in the prompt; that trade-off is discussed in Context Engineering for Agents and applied to middleware in Middleware. To inspect what an agent read and wrote, see Observability with LangSmith.