LangGraph Memory and State Persistence: Checkpointers, Threads, and Cross-Session Memory
The Amnesia Problem: Why Stateless Agents Frustrate Users
Checkpointers + thread IDs give LangGraph agents persistent memory across turns and sessions.
🎯 System Scenario & Requirements
System Goal
Checkpointers + thread IDs give LangGraph agents persistent memory across turns and sessions.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: Checkpointers + thread IDs give LangGraph agents persistent memory across turns and sessions.
- Your customer support agent is on its third message with a user.
- The user says: "As I mentioned before, my order number is 9847." The agent replies: "Could you please provide your order number so I can look that up?" The agent hasn't forgotten.
Constraints & Assumptions
- Storage Costs Compound Quickly What happens on Turn 2: LangGraph loads the checkpoint from Turn 1 (which already has Alice's first message and the assistant's reply).
- The new HumanMessage is appended via operator.add .
⚡ Execution Stages & Decision Trace
Stage 1 — Checkpointers: InMemorySaver, SqliteSaver, and PostgresSaver
A checkpointer is an object you attach at compile() time.
After every node runs, LangGraph calls the checkpointer to write the current state to storage.
Deep Dive
Scenario context
Checkpointers + thread IDs give LangGraph agents persistent memory across turns and sessions.
Your customer support agent is on its third message with a user. The user says: "As I mentioned before, my order number is 9847." The agent replies: "Could you please provide your order number so I can look that up?"
The agent hasn't forgotten. It never knew. Every call to graph.invoke() without a checkpointe…
This walkthrough focuses on The Amnesia Problem: Why Stateless Agents Frustrate Users.
Before making a design choice, clarify:
- Your customer support agent is on its third message with a user.
- The user says: "As I mentioned before, my order number is 9847." The agent replies: "Could you please provide your order number so I can look that up?" The agent hasn't forgotten.
- Every call to graph.invoke() without a checkpointer starts with a completely empty state — zero messages, zero context, no trace of anything said before.
Concepts in play: llm-agents-and-langgraph, graph-traversal-algorithms, api-gateway, jvm-internals-and-garbage-collection
Stage 1 — Checkpointers: InMemorySaver, SqliteSaver, and PostgresSaver
- A checkpointer is an object you attach at compile() time.
- After every node runs, LangGraph calls the checkpointer to write the current state to storage.
- On the next invoke() with the same thread ID, LangGraph calls the checkpointer to read the latest snapshot before the graph starts running.
- LangGraph ships three checkpointers out of the box: InMemorySaver — Development and Unit Tests State lives in a Python dictionary in RAM.
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
from langgraph.checkpoint.sqlite import SqliteSaver
# Context-manager handles connection lifecycle
with SqliteSaver.from_conn_string("./agent_memory.db") as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
result = graph.invoke({"messages": [...]}, config=config)
Observable outcome: LangGraph ships three checkpointers out of the box: InMemorySaver — Development and Unit Tests State lives in a Python dictionary in RAM.
Stage 2 — Deep Dive: How LangGraph Checkpointing Works Under the Hood
- The Internals When LangGraph compiles a graph with a checkpointer, it wraps every node execution in a checkpoint lifecycle: 1.
- Before the graph runs: LangGraph calls checkpointer.get tuple(config) .
- If a checkpoint exists for the given thread id , it deserializes the state and uses it as the starting values.
- If not, the graph starts fresh.
# The config dict is the entire addressing scheme for LangGraph persistence
config = {
"configurable": {
"thread_id": "user-alice-session-42", # conversation namespace
"checkpoint_id": None, # None = latest; set a UUID to time-travel
}
}
Observable outcome: If not, the graph starts fresh.
Stage 3 — Thread Isolation and State Flow
- The diagram below shows two users — Alice and Bob — both interacting with the same compiled graph.
- Their state never touches because thread id keeps every checkpoint in its own namespace within the same checkpointer backend.
- Two conversations share one graph instance and one checkpointer backend, but their state is completely isolated by thread id .
- Each arrow to the checkpointer is a separate row in the checkpoints table.
graph TD
subgraph Alice[Thread: user-alice]
A1([invoke turn 1]) --> A2[classify_intent node]
A2 --> A3[fetch_order node]
A3 --> A4[respond node]
A4 --> A5([invoke turn 2 resumes from A4 snapshot])
end
subgraph Bob[Thread: user-bob]
B1([invoke turn 1]) --> B2[classify_intent node]
B2 --> B3[respond node]
B3 --> B4([invoke turn 2 resumes from B3 snapshot])
end
CP[(Checkpointer PostgresSaver)]
A4 -- "writes checkpoint thread=user-alice" --> CP
B3 -- "writes checkpoint thread=user-bob" --> CP
A5 -- "reads checkpoint thread=user-alice" --> CP
B4 -- "reads checkpoint thread=user-bob" --> CP
# Snapshot of Alice's current conversation state
state = graph.get_state({"configurable": {"thread_id": "user-alice"}})
print(state.values["messages"]) # full message list
print(state.next) # next node(s) that would run
# Full audit trail — every checkpoint in reverse-chronological order
history = list(graph.get_state_history({"configurable": {"thread_id": "user-alice"}}))
for snapshot in history:
print(f"Step {snapshot.config['configurable']['checkpoint_id']}: "
f"next={snapshot.next}, messages={len(snapshot.values['messages'])}")
Observable outcome: Each arrow to the checkpointer is a separate row in the checkpoints table.
Stage 4 — Real-World Applications: How Production Agents Use Persistent Memory
- Case Study 1 — E-Commerce Support Agent An online retailer runs a LangGraph support agent with PostgresSaver .
- When a customer opens a chat: - Input: thread id = f"customer-{customer id}-{ticket id}" - Process: The graph loads prior messages for this ticket.
- If the customer said "I already tried resetting my password" three turns ago, the agent routes to escalation rather than suggesting the same fix again.
- Output: First-contact resolution rate improved ~18% in one deployment because agents stopped repeating questions.
# Agent drafts report, hits "awaiting_approval" interrupt node, and pauses
result = graph.invoke({"messages": [...]}, config=config)
# result.next == ("awaiting_approval",) — graph is suspended
# Hours later, manager approves via dashboard — graph resumes from exact checkpoint
graph.invoke(
Command(resume={"approved": True, "approver": "manager@co.com"}),
config=config
)
Observable outcome: - Output: First-contact resolution rate improved ~18% in one deployment because agents stopped repeating questions.
Stage 5 — LangGraph Memory Store: Long-Term Semantic Memory Beyond the Conversation
- The BaseStore interface is LangGraph's answer to memory that outlives any single thread.
- It is a key-value store organized into namespaces (tuples of strings) with keys (string identifiers) and values (arbitrary dicts).
- For production, replace InMemoryStore with AsyncPostgresStore : Message Trimming to Prevent Context Overflow As conversations grow, trim old messages before passing them to the LLM.
- LangChain's trim messages utility handles this cleanly: For very long-running agents, combine trimming with a summarization node that periodically condenses old messages into a single summary AIMessage , reducing histor…
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
# Write a user preference (namespace, key, value)
store.put(("user_profiles", "alice"), "preferences", {
"language": "en",
"contact_channel": "email",
"last_order": "9847"
})
# Read it back in any future session
results = store.search(("user_profiles", "alice"))
if results:
prefs = results[0].value
print(prefs["contact_channel"]) # "email"
# Update — put() is upsert, same namespace+key overwrites
store.put(("user_profiles", "alice"), "preferences", {
**prefs,
"last_order": "10234"
})
from langgraph.store.postgres.aio import AsyncPostgresStore
store = AsyncPostgresStore.from_conn_string(DB_URI)
await store.setup() # creates the store table on first run
Observable outcome: LangChain's trim messages utility handles this cleanly: For very long-running agents, combine trimming with a summarization node that periodically condenses old messages into a single summary AIMessage , reducing histor…
End-to-end outcome
After working through the stages, you should be able to explain:
- Pick your checkpointer before you write any agent code.
- Switching checkpointers later means migrating stored state — InMemorySaver checkpoints cannot be imported into SqliteSaver .
Alternatives considered
Memory Fundamentals: Short-Term State vs Long-Term Store vs External Memory
LangGraph offers three distinct memory layers, and conflating them is the most common source of confusion when building persistent agents.
Failure and degraded modes
Trade-offs and Failure Modes: Storage Costs, Context Overflow, and Stale Memory
- Detection: Storage Costs Compound Quickly What happens on Turn 2: LangGraph loads the checkpoint from Turn 1 (which already has Alice's first message and the assistant's reply).
- User or operator impact: Storage Costs Compound Quickly What happens on Turn 2: LangGraph loads the checkpoint from Turn 1 (which already has Alice's first message and the assistant's reply).
