Code Advanced hard · 8 min

Multi-tenant isolation in production

What you will learn
Prevent state leakage between concurrent users by implementing thread-safe memory backends with tenant-scoped checkpointing.

Why this matters

In production, a single LangGraph server handles requests from multiple users simultaneously. Without proper isolation, one user's conversation state can leak into another user's thread, causing data breaches, incorrect responses, and compliance violations. This is not theoretical: it's a real vulnerability in naive implementations.

Skip if: You don't need multi-tenant isolation if you're building a single-user desktop application, running isolated containers per user, or using a commercial LLM API that manages state for you. You also don't need it if your graph has zero mutable state (stateless transformations only).

Explanation

What it is: Multi-tenant isolation ensures that when multiple users invoke the same LangGraph, each user's state (messages, variables, checkpoints) remains completely separate and invisible to other concurrent users. Without this, a user might accidentally read or modify another user's conversation.

How it works mechanically: LangGraph's StateGraph stores state in memory by default. When you call graph.invoke(input, config={'configurable': {'user_id': 'alice'}}), the config parameter is the isolation mechanism. This configurable dict is passed to your Checkpointer (usually MemorySaver). The checkpointer uses this config to namespace state storage: so 'alice' and 'bob' write to separate memory partitions. The thread ID in the config also matters: same user, different conversation = different thread = separate state.

When to use it: Always use this pattern in production when serving multiple users or when the same graph handles concurrent requests. The cost is minimal (a dict key): the risk of not doing it is catastrophic.

Analogy

Think of a bank with multiple tellers. If tellers don't check the customer's ID before accessing the account ledger, they might serve Alice's withdrawal from Bob's account. The <code>config</code> parameter is the ID check. Without it, concurrent customers interfere with each other.

Code

python
import uuid
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver


class ConversationState(TypedDict):
    user_id: str
    messages: list[str]
    context: str


def greet_node(state: ConversationState) -> ConversationState:
    return {
        **state,
        "messages": state["messages"] + [f"Hello, user {state['user_id']}!"],
    }


def process_node(state: ConversationState) -> ConversationState:
    return {
        **state,
        "context": f"Processing for {state['user_id']}",
    }


builder = StateGraph(ConversationState)
builder.add_node("greet", greet_node)
builder.add_node("process", process_node)
builder.add_edge(START, "greet")
builder.add_edge("greet", "process")
builder.add_edge("process", END)

checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)


def simulate_concurrent_users():
    user_a_id = "user_alice"
    user_b_id = "user_bob"
    thread_id = str(uuid.uuid4())
    
    initial_state_a = {
        "user_id": user_a_id,
        "messages": ["User A query"],
        "context": "",
    }
    
    initial_state_b = {
        "user_id": user_b_id,
        "messages": ["User B query"],
        "context": "",
    }
    
    config_a = {
        "configurable": {
            "user_id": user_a_id,
            "thread_id": thread_id,
        }
    }
    
    config_b = {
        "configurable": {
            "user_id": user_b_id,
            "thread_id": thread_id,
        }
    }
    
    result_a = graph.invoke(initial_state_a, config=config_a)
    result_b = graph.invoke(initial_state_b, config=config_b)
    
    print(f"User A final state: {result_a}")
    print(f"User B final state: {result_b}")
    print(f"\nUser A messages: {result_a['messages']}")
    print(f"User B messages: {result_b['messages']}")
    print(f"\nUser A context: {result_a['context']}")
    print(f"User B context: {result_b['context']}")
    
    assert result_a['user_id'] == 'user_alice', "User A state corrupted"
    assert result_b['user_id'] == 'user_bob', "User B state corrupted"
    assert 'alice' in result_a['messages'][1], "User A greeting missing ID"
    assert 'bob' in result_b['messages'][1], "User B greeting missing ID"
    print(f"\n✓ Isolation verified: Each user has separate state")


simulate_concurrent_users()
Output
User A final state: {'user_id': 'user_alice', 'messages': ['User A query', 'Hello, user user_alice!'], 'context': 'Processing for user_alice'}
User B final state: {'user_id': 'user_bob', 'messages': ['User B query', 'Hello, user user_bob!'], 'context': 'Processing for user_bob'}

User A messages: ['User A query', 'Hello, user user_alice!']
User B messages: ['User B query', 'Hello, user user_bob!']

User A context: Processing for user_alice
User B context: Processing for user_bob

✓ Isolation verified: Each user has separate state

What just happened?

We created a LangGraph with mutable state, then invoked it twice with different user IDs in the config: both using the same thread ID. The checkpointer internally namespaced the storage by combining user_id and thread_id, ensuring that even though both users share a thread ID, their state dictionaries remain completely separate. User A's messages list never contains User B's data, and vice versa. The <code>config</code> parameter is the isolation gate.

Common gotcha

Developers often omit the config parameter entirely or use the same config for multiple users, assuming LangGraph 'just handles it.' In reality, without a unique identifier per user in the config, all concurrent invocations write to the same state partition in memory, causing state to merge or overwrite. The fix is mandatory: always pass config={'configurable': {'user_id': unique_user_id}} on every invoke() call in production.

Error recovery

State bleeding between users
You're not passing user-specific config. Add config={'configurable': {'user_id': request.user.id, 'thread_id': session_id}} to every graph.invoke() call.
MemorySaver not isolating
MemorySaver is not designed for true multi-process/multi-machine scenarios. For horizontal scaling, use PostgresSaver or SQLiteSaver with a database backend: memory-only checkpointers don't survive process restarts and can't share state across server instances.
Same user, different conversations mixing
You're reusing the same thread_id for different conversations. Each new conversation needs a unique thread_id. Use uuid.uuid4() for new conversations, preserve thread_id only to resume the same conversation.

Experienced dev note

The real gotcha: LangGraph doesn't prevent you from writing unsafe code. You could pass user_id as a node parameter instead of in config, manually store state in a global dict, or build a graph with no configurable isolation at all: it will 'work' until two users hit it simultaneously. The pattern here (config dict with user_id) isn't magic enforcement; it's a discipline. Always audit the path from HTTP request → config dict → checkpointer. If you skip any step, you've broken isolation. One more thing: if you're using an LLM as a node, the LLM itself doesn't see the config: only your nodes do. Design your prompts so the LLM uses the user_id from state, not from some global context.

Check your understanding

You have a graph that maintains conversation history in its state. Two users are invoking the same graph with the same thread_id but different user_ids in their config dict. Why doesn't User A see User B's messages, even though they share the same thread?

Show answer hint

A correct answer explains that the checkpointer (MemorySaver, PostgresSaver, etc.) composites the user_id and thread_id to create a unique storage key. The thread_id alone is not sufficient for isolation: it's the combination of (user_id, thread_id) that creates separate state partitions. The configurable dict is passed to the checkpointer, not visible to nodes, and it controls how state is namespaced at the checkpoint layer.

VERSION In langgraph < 0.1.0, the config API was inconsistent and checkpointing was unreliable for multi-tenancy. Version 0.2.x stabilized the config dict pattern and made MemorySaver reliably thread-safe for this use case. Always upgrade to 0.2.x or later for production multi-tenant workloads.
NEXT

Learn how to upgrade from MemorySaver to PostgresSaver for persistent, distributed state management across multiple server instances without losing isolation guarantees.

Community Notes

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