Code Intermediate medium · 6 min

thread_id: isolating concurrent conversations

What you will learn
Use thread_id to keep separate conversations independent so one user's state doesn't leak into another's.

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.

Skip if: If you're building a single-user debugging tool or a one-shot batch processor with no concurrent requests, thread_id isolation is unnecessary overhead. Only add it when you expect parallel graph executions.

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

python
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))
Output
=== 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 access
If you forget to pass config={"configurable": {"thread_id": ...}}, LangGraph will raise a KeyError when it tries to access the thread_id. Always wrap thread_id in a dict under the "configurable" key.
State not persisting
If you compile without checkpointer=MemorySaver(), the graph won't save state at all: each invoke is a fresh start. Always pass checkpointer=MemorySaver() to compile() for persistence.
Checkpointer not imported
If you write MemorySaver() without importing it, you'll get NameError. Import it: from langgraph.checkpoint.memory import MemorySaver

Experienced 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.

VERSION In langgraph < 0.2.0, checkpointing and thread isolation were less explicit. The pattern was similar but the config structure and checkpoint API changed in 0.2.x: always use the config={"configurable": {"thread_id": ...}} pattern with modern langgraph.
NEXT

Next, explore <strong>persistence across restarts</strong> by replacing MemorySaver with a real database-backed checkpointer like PostgresSaver: same thread_id pattern, but state survives your application restarting.

Community Notes

No notes yetBe the first to share a version-specific fix or tip.