thread_id: isolating concurrent conversations
Why this matters
In production, your graph runs concurrently for multiple users. Without thread_id isolation, one conversation's memory and decisions can contaminate another's: causing wrong answers, privacy leaks, and angry users.
Explanation
What it is: thread_id is a unique identifier passed to graph.invoke() via the config parameter that tells LangGraph to store and retrieve state separately for each conversation thread. Think of it as the conversation's passport number.
How it works mechanically: When you call graph.invoke(input, config={"configurable": {"thread_id": "user_123"}}), LangGraph uses a MemorySaver checkpoint to store the graph's state keyed by that thread_id. On the next invoke with the same thread_id, LangGraph retrieves that saved state instead of starting fresh. Different thread_ids get completely isolated state buckets: they never see each other's memory.
When to use it: Always use thread_id in production systems that serve multiple concurrent users or sessions. It's the default pattern for multi-turn conversations, chatbots, multi-user APIs, and any system where state persistence matters per user/session.
Analogy
Like a bank teller serving multiple customers in separate cubicles. Each customer (thread_id) has their own transaction history (state) written in a separate ledger. The teller doesn't accidentally read customer B's account when helping customer A: the thread_id ensures the right ledger is opened.
Code
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
import json
class ConversationState(TypedDict):
messages: list[str]
user_context: str
turn_count: int
def process_input(state: ConversationState) -> ConversationState:
new_message = f"Turn {state['turn_count']}: Processing in context '{state['user_context']}'"
return {
"messages": state["messages"] + [new_message],
"user_context": state["user_context"],
"turn_count": state["turn_count"] + 1
}
def update_context(state: ConversationState) -> ConversationState:
return {
"messages": state["messages"],
"user_context": state["user_context"] + " [updated]",
"turn_count": state["turn_count"]
}
graph = StateGraph(ConversationState)
graph.add_node("process", process_input)
graph.add_node("update", update_context)
graph.add_edge(START, "process")
graph.add_edge("process", "update")
graph.add_edge("update", END)
checkpointer = MemorySaver()
compiled_graph = graph.compile(checkpointer=checkpointer)
initial_state = {
"messages": [],
"user_context": "user_alice",
"turn_count": 1
}
print("=== User Alice, Thread 1 ===")
result_alice_1 = compiled_graph.invoke(
initial_state,
config={"configurable": {"thread_id": "alice_thread_1"}}
)
print(json.dumps(result_alice_1, indent=2))
print("\n=== User Bob, Thread 1 ===")
initial_state_bob = {
"messages": [],
"user_context": "user_bob",
"turn_count": 1
}
result_bob_1 = compiled_graph.invoke(
initial_state_bob,
config={"configurable": {"thread_id": "bob_thread_1"}}
)
print(json.dumps(result_bob_1, indent=2))
print("\n=== User Alice, Thread 1 Again (resuming) ===")
result_alice_2 = compiled_graph.invoke(
{"messages": [], "user_context": "", "turn_count": 0},
config={"configurable": {"thread_id": "alice_thread_1"}}
)
print(json.dumps(result_alice_2, indent=2))
print("\n=== User Alice, Thread 2 (new conversation) ===")
result_alice_3 = compiled_graph.invoke(
initial_state,
config={"configurable": {"thread_id": "alice_thread_2"}}
)
print(json.dumps(result_alice_3, indent=2)) === User Alice, Thread 1 ===
{
"messages": [
"Turn 1: Processing in context 'user_alice'"
],
"user_context": "user_alice [updated]",
"turn_count": 2
}
=== User Bob, Thread 1 ===
{
"messages": [
"Turn 1: Processing in context 'user_bob'"
],
"user_context": "user_bob [updated]",
"turn_count": 2
}
=== User Alice, Thread 1 Again (resuming) ===
{
"messages": [
"Turn 1: Processing in context 'user_alice'",
"Turn 2: Processing in context 'user_alice [updated]'"
],
"user_context": "user_alice [updated] [updated]",
"turn_count": 3
}
=== User Alice, Thread 2 (new conversation) ===
{
"messages": [
"Turn 1: Processing in context 'user_alice'"
],
"user_context": "user_alice [updated]",
"turn_count": 2
} What just happened?
We created a graph with two nodes and compiled it with <code>MemorySaver()</code>. Then we invoked it four times with different thread_ids. When we used <code>alice_thread_1</code> twice, the second invoke resumed from the saved state (turn_count was 2, messages persisted, context had [updated] appended). When we used <code>bob_thread_1</code>, it started fresh with its own context. When we used <code>alice_thread_2</code>, it also started fresh even though it was Alice: because it's a different thread_id. Each thread_id maintained its own isolated state bucket.
Common gotcha
The most common mistake is passing the same initial state on every invoke and expecting it to resume. The graph only resumes if you invoke with the SAME thread_id: the initial state you pass doesn't override saved state, it only provides defaults for the first invoke with that thread_id. If you invoke the same thread_id twice with different input, the second invoke loads the saved state from the first and builds on it, ignoring most of the input you passed.
Error recovery
KeyError on config accessState not persistingCheckpointer not importedExperienced dev note
In production, thread_id should NOT be auto-generated: it must be deterministic and tied to your identity layer (user_id, session_id, conversation_id from your database). If you regenerate thread_ids, you lose all conversation history. Also, MemorySaver is in-memory only: it clears on process restart. For production, use a real persistence layer like PostgresSaver or SQLiteSaver with a proper database. The pattern stays the same, just swap the checkpointer.
Check your understanding
If User Alice invokes thread_id='alice_1' twice, and between those invokes User Bob invokes thread_id='bob_1', why won't Alice's second invoke see any of Bob's state changes?
Show answer hint
The answer hinges on understanding that thread_id is the isolation key: each thread_id has its own completely separate state bucket in the checkpointer. Bob's invoke writes to bob_1's bucket; Alice's second invoke reads from alice_1's bucket. They never share the same state storage, so Alice never sees Bob's changes no matter what order invokes happen in.