AI Agents LangGraph

StateGraph vs MessageGraph

Intermediate

StateGraph vs MessageGraph

In this topic, we explore the differences between StateGraph and MessageGraph in LangGraph, how each graph type works, and when to use them in real AI applications. We cover state-based and message-based execution, shared state management, message handling, reducers, memory, routing, tool calling, and multi-agent workflows.

We also compare flexibility, performance, simplicity, and scalability, discuss modern LangGraph best practices, and explain why most advanced AI agents are built using StateGraph . Finally, we review common mistakes and best practices for designing clean and maintainable graph architectures.

StateGraph vs MessageGraph

Before developing an AI agent in LangGraph, we first create an instance of either StateGraph or MessageGraph . We then build the workflow by adding nodes and edges to the graph. In this topic, we explore these two core LangGraph graph classes.

What Is StateGraph?

StateGraph is the main and most flexible graph builder in LangGraph. It allows you to define your own custom state schema and build complex, stateful workflows. You explicitly define what your state should look like (using TypedDict , Pydantic models, etc.), and LangGraph handles merging updates from each node according to the rules you set (reducers). Use Case: Any kind of workflow — agents, RAG, multi-step reasoning, business logic, etc.
Basic Example:
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    next: str
    iterations: int

graph = StateGraph(AgentState)   # ← Define custom state

graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_tools)
graph.add_edge("tools", "agent")

graph.add_edge("agent", END)

What Is MessageGraph?

MessageGraph is a specialized, simplified subclass of StateGraph designed specifically for conversational and chat-based applications. It comes with a pre-defined state: just a list of messages. You don’t need to define your own state schema. Each node receives a list of messages and should return one or more new messages. LangGraph automatically appends them using the add_messages reducer.
Simple Example:
from langgraph.graph import MessageGraph, END
from langchain_core.messages import HumanMessage, AIMessage

graph = MessageGraph()   # No state schema needed

def chatbot(state):
    response = llm.invoke(state)          # state is list of messages
    return [AIMessage(content=response.content)]

graph.add_node("chatbot", chatbot)
graph.set_entry_point("chatbot")
graph.add_edge("chatbot", END)

app = graph.compile()

result = app.invoke([HumanMessage(content="Hello!")])

Why LangGraph Has Two Graph Types

Aspect StateGraph MessageGraph
Flexibility Very High Limited
State Definition User-defined Pre-defined ( list[BaseMessage] )
Best For Complex agents, RAG systems, multi-agent workflows, tool orchestration Simple chatbots and quick prototypes
Boilerplate More setup required (define custom state) Minimal setup
Reducers Fully customizable Fixed to add_messages

Reason for both:

  • StateGraph gives you full power for production-grade applications.
  • MessageGraph gives you speed and simplicity for message-heavy conversational flows.

Core Difference Between StateGraph and MessageGraph

  • StateGraph = General purpose → You control the entire state.
  • MessageGraph = Opinionated shortcut → Optimized only for messages.
Modern Recommendation (2025+):
Most developers now use StateGraph(MessagesState) instead of MessageGraph , because it gives the same simplicity as MessageGraph but with the flexibility to add extra fields later.
from langgraph.graph import StateGraph
from langgraph.graph.message import MessagesState

graph = StateGraph(MessagesState)   # ← Most common today

How StateGraph Works

  1. You define a state schema.
  2. You add nodes (functions that take state and return updates).
  3. LangGraph merges the updates using reducers.
  4. Execution flows according to your edges.
Full flow:
START → Node1 (update state) → Node2 (update state) → ... → END

How MessageGraph Works

  1. State is automatically a list of messages.
  2. Every node receives list[BaseMessage].
  3. Every node returns list[BaseMessage] (or single message).
  4. add_messages reducer automatically appends new messages.
It’s essentially a StateGraph with MessagesState pre-configured and add_messages as the default reducer.

State-Based Execution vs Message-Based Execution

Aspect State-Based Execution ( StateGraph ) Message-Based Execution ( MessageGraph )
What flows through the graph Full custom state ( dict / object) Only a list of messages
Flexibility Very High Limited
Use Case Complex agents, RAG systems, multi-agent workflows, business logic Simple chatbots and conversational flows
State Management You define reducers for every field Primarily uses the add_messages reducer
Data Beyond Messages Easy to store (documents, metadata, tools, memory, etc.) Difficult and limited
Custom State Fields Fully supported Limited support
Complexity More advanced Simpler
Scalability Better for large systems Better for lightweight chat flows
Recommended For Production-grade AI agents Beginner-friendly conversational apps
State-Based is more powerful and future-proof.
Message-Based is simpler and faster for basic chat use cases.

State Structure in StateGraph

In StateGraph , you fully control the structure of the state.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    # Core conversation history
    messages: Annotated[list, add_messages]
    
    # Custom fields
    user_info: dict
    documents: list[dict]
    iterations: int
    next: str | None          # Used for supervisor routing
    error: str | None
    confidence: float
Key Rules:
  • Every key in the state can have its own reducer (how updates are merged).
  • Most common reducer is add_messages for the messages field.
  • You can add as many fields as needed.

Message Flow in MessageGraph

In MessageGraph , the entire state is just a list of messages.
from langgraph.graph import MessageGraph
from langchain_core.messages import HumanMessage, AIMessage

graph = MessageGraph()

def chatbot(state):                    # state = list[BaseMessage]
    response = llm.invoke(state)       # LLM gets full history
    return [AIMessage(content=response.content)]   # Must return list

graph.add_node("chatbot", chatbot)
graph.set_entry_point("chatbot")
graph.add_edge("chatbot", END)

app = graph.compile()

result = app.invoke([HumanMessage(content="What is LangGraph?")])

Important : Every node receives the full message history and should return new message(s).

TypedDict and Pydantic in StateGraph

You have two main ways to define state in StateGraph:
1. Using TypedDict (Most Common)
from typing import TypedDict, Annotated

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    documents: Annotated[list, lambda a, b: a + b]   # Custom reducer
    iterations: int
2. Using Pydantic (Recommended for complex validation)
from pydantic import BaseModel, Field
from typing import Annotated

class AgentState(BaseModel):
    messages: Annotated[list, add_messages] = Field(default_factory=list)
    documents: list[dict] = Field(default_factory=list)
    iterations: int = 0
    confidence: float = 0.0

graph = StateGraph(AgentState)

Recommendation (2025+):
Use Pydantic for better type checking, defaults, and validation.
Use TypedDict for simpler, lightweight graphs

Message Objects in MessageGraph

MessageGraph works exclusively with LangChain message objects:
  • HumanMessage
  • AIMessage
  • SystemMessage
  • ToolMessage
  • FunctionMessage (legacy)
from langchain_core.messages import (
    HumanMessage, 
    AIMessage, 
    ToolMessage
)

def tool_node(state):
    # state is list of messages
    tool_calls = state[-1].tool_calls
    results = execute_tools(tool_calls)
    
    # Return ToolMessage
    return [ToolMessage(content=results, tool_call_id=tool_calls[0]["id"])]
Note: Even when using StateGraph(MessagesState) , you still work with these same message objects.

HumanMessage, AIMessage, SystemMessage, ToolMessage

LangGraph (and LangChain) uses a standardized set of message classes to represent conversation history. These are the building blocks of both StateGraph and MessageGraph .
Message Type Purpose Common Attributes Used By
HumanMessage Represents user input content User
AIMessage Represents assistant / LLM responses content , tool_calls LLM / Agent
SystemMessage Provides instructions or system prompts content Developer / System
ToolMessage Represents the result of a tool execution content , tool_call_id ToolNode / Tools
Code Example – Creating Messages
from langchain_core.messages import (
    HumanMessage,
    AIMessage,
    SystemMessage,
    ToolMessage
)

messages = [
    SystemMessage(content="You are a helpful assistant."),
    HumanMessage(content="What is LangGraph?"),
    AIMessage(content="LangGraph is a library for building stateful agents.", tool_calls=[...]),
    ToolMessage(content="Tool executed successfully", tool_call_id="call_123"),
]
These message objects are what get passed around in the messages field of your state.

Shared State Management

Both StateGraph and MessageGraph use the same underlying state management system.
  • MessageGraph is actually a thin wrapper around StateGraph with a pre-defined MessagesState .
  • The core mechanism ( reducers , merging updates, checkpointers) is identical.
Key Point:
You can easily convert any MessageGraph into a StateGraph(MessagesState) , they behave almost the same under the hood.

Chat History Handling

In both graph types , chat history is stored in the messages field and is automatically managed by the add_messages reducer.
from langgraph.graph.message import add_messages
from typing import Annotated, TypedDict

class MessagesState(TypedDict):
    messages: Annotated[list, add_messages]   # ← Magic happens here

How it works:

  • Every time a node returns new messages, add_messages appends them.
  • The full conversation history is passed to the LLM on every call.
  • You can trim or summarize history to avoid token limits.
Example of automatic appending:
def agent_node(state):
    response = llm.invoke(state["messages"])   # Full history
    return {"messages": [response]}            # New message is appended

Reducers in StateGraph

Reducers tell LangGraph how to merge updates from different nodes into the shared state. The most important built-in reducer is add_messages .
Common Reducers
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
from operator import add

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]           # Append messages
    documents: Annotated[list, add]                   # Concatenate lists
    iterations: int                                   # Last write wins (default)
    metadata: Annotated[dict, lambda a, b: {**a, **b}]  # Merge dicts

Custom Reducer Example:

def custom_reducer(left: list, right: list):
    return left + [x for x in right if x not in left]  # Deduplicate

class AgentState(TypedDict):
    unique_docs: Annotated[list, custom_reducer]

In MessageGraph:

Only add_messages reducer is available (and pre-configured).

Memory Handling Differences

Feature StateGraph MessageGraph
Checkpointer Support Full support Full support
Persistent Memory Yes ( SQLite , Postgres , etc.) Yes
Multi-thread / Session Support Yes (using thread_id ) Yes
Extra State Fields Fully supported Not supported (messages only)
Example using MemorySaver (works identically in both):
from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()

app = graph.compile(checkpointer=checkpointer)

# Run with thread_id for memory
config = {"configurable": {"thread_id": "user_123"}}
result = app.invoke({"messages": [HumanMessage(content="Hi")]}, config)

Key Difference:
In StateGraph you can persist any custom fields (documents, user preferences, etc.). In MessageGraph you can only persist the message history.

Tool Calling in Both Graph Types

Tool calling works almost identically in both graph types.
In StateGraph (Recommended)
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, MessagesState

graph = StateGraph(MessagesState)

graph.add_node("agent", agent_node)           # LLM with .bind_tools()
graph.add_node("tools", ToolNode(tools))

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_tools)
graph.add_edge("tools", "agent")
In MessageGraph (Simpler but Limited)
from langgraph.graph import MessageGraph

graph = MessageGraph()

graph.add_node("agent", agent_node)
graph.add_node("tools", ToolNode(tools))

graph.set_entry_point("agent")
graph.add_conditional_edges("agent", route_tools)
graph.add_edge("tools", "agent")
Important Note:
Even though MessageGraph is simpler, most developers now prefer StateGraph(MessagesState) because it allows you to easily add extra state fields later (e.g., documents , iterations , user_id ).

Conditional Routing in StateGraph vs MessageGraph

Conditional routing works almost identically in both, but the state you have access to differs.
In StateGraph (Recommended)
You have access to your full custom state, giving you much more powerful routing logic.
from langgraph.graph import StateGraph, MessagesState

def route_tools(state: MessagesState):
    last_message = state["messages"][-1]
    
    # You can also check custom fields
    if state.get("iterations", 0) > 10:
        return "END"
    if last_message.tool_calls:
        return "tools"
    if "research" in last_message.content.lower():
        return "research_node"
    return "END"


graph = StateGraph(MessagesState)
graph.add_conditional_edges("agent", route_tools)
In MessageGraph
You can only route based on the message list.
from langgraph.graph import MessageGraph

def route_tools(state):   # state = list[BaseMessage]
    last_message = state[-1]
    if last_message.tool_calls:
        return "tools"
    return "END"


graph = MessageGraph()
graph.add_conditional_edges("agent", route_tools)
Winner: StateGraph , much more flexible for complex routing.

Multi-Agent Workflows

Both graph types support multi-agent systems, but StateGraph is far superior.
Using StateGraph (Best Practice)
from langgraph.graph import StateGraph, MessagesState, START, END

def supervisor_router(state: MessagesState):
    last = state["messages"][-1].content.lower()
    
    if "research" in last:
        return "research_agent"
    elif "code" in last:
        return "coder_agent"
    else:
        return "END"

graph = StateGraph(MessagesState)

graph.add_node("supervisor", supervisor_node)
graph.add_node("research_agent", research_agent)
graph.add_node("coder_agent", coder_agent)

graph.add_conditional_edges("supervisor", supervisor_router)

graph.add_edge("research_agent", "supervisor")
graph.add_edge("coder_agent", "supervisor")

graph.add_edge(START, "supervisor")
graph.add_edge("supervisor", END)

You can also add extra state fields like current_agent, task_status, etc. MessageGraph can do basic multi-agent but lacks the ability to store shared context easily.

Streaming Support

Both graphs support excellent streaming, but StateGraph gives you more control.
app = graph.compile()

# Stream final state
for chunk in app.stream(inputs, stream_mode="values"):
    print(chunk)

# Stream only messages (very popular)
for chunk in app.stream(inputs, stream_mode="messages"):
    print(chunk)

# Stream updates from specific nodes
for chunk in app.stream(inputs, stream_mode="updates"):
    print(chunk)
Advanced Streaming with StateGraph:
# Custom state streaming
for chunk in app.stream(
    {"messages": [HumanMessage(content="Hello")]},
    stream_mode="values"
):
    if "messages" in chunk:
        print("Assistant:", chunk["messages"][-1].content)

Persistence and Checkpointing

Both support the same powerful checkpointing system.
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver

# In-memory (development)
checkpointer = MemorySaver()

# Persistent (production)
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")

app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "user_123"}}

# Run and save state
result = app.invoke(inputs, config)

# Later, continue the same conversation
result2 = app.invoke({"messages": [HumanMessage(content="Continue...")]}, config)
Advantage of StateGraph : You can persist custom fields (documents, user preferences, agent memory, etc.), while MessageGraph only persists messages.

Performance Considerations

Aspect StateGraph MessageGraph
Overhead Slightly higher (custom state management) Very lightweight
Best For Complex agents and workflows Simple chatbots and conversational flows
Memory Usage Depends on your state design Generally lower
Scaling Excellent with good state architecture Good for basic use cases
Tips for Better Performance:
  • Use MessagesState instead of very large custom states when possible.
  • Summarize long message histories.
  • Use faster models for routing decisions.
  • Stream responses to users instead of waiting for full completion.

Flexibility Comparison

Feature StateGraph MessageGraph Winner
Custom State Fields Yes No StateGraph
Custom Reducers Yes No StateGraph
Multi-Agent & Supervisor Support Excellent Basic StateGraph
Extra Data (documents, metadata, etc.) Easy Difficult StateGraph
Development Speed Medium Very Fast MessageGraph
Future Extensibility Excellent Limited StateGraph
Learning Curve Slightly Steeper Very Easy MessageGraph
Modern Best Practice
from langgraph.graph import StateGraph
from langgraph.graph.message import MessagesState

# Use this pattern for almost everything
graph = StateGraph(MessagesState)
This gives you the simplicity of MessageGraph + the full power of StateGraph.

Simplicity vs Control

Aspect MessageGraph StateGraph
Simplicity Very High (minimal boilerplate) Moderate (requires state definition)
Control Limited Full control over state and reducers
Development Speed Extremely fast for simple chatbots Slightly slower at the beginning, but scales better
Long-term Maintenance Can become limiting Much easier to extend and maintain
MessageGraph prioritizes speed and simplicity .
StateGraph prioritizes control and scalability .

When to Use StateGraph

Use StateGraph when you need:
  • Custom state fields (documents, metadata, user preferences, iterations, etc.)
  • Complex multi-agent or supervisor workflows
  • Advanced state management with custom reducers
  • RAG pipelines with retrieved context
  • Long-running agents that need memory beyond messages
  • Production-grade, maintainable applications
Recommended Modern Pattern:
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from typing import Annotated

class AgentState(MessagesState):          # Inherit from MessagesState
    documents: list[dict] = []
    iterations: int = 0
    next_agent: str | None = None

graph = StateGraph(AgentState)

graph.add_node("agent", agent_node)
graph.add_node("tools", ToolNode(tools))
graph.add_node("retriever", retrieval_node)

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_tools)
graph.add_edge("tools", "agent")
graph.add_edge("retriever", "agent")

When to Use MessageGraph

Use MessageGraph when:
  • Building quick prototypes or simple chatbots
  • You only need basic conversation flow
  • You want the absolute minimum code
  • You don’t need any state beyond message history
  • You are experimenting or teaching basics
Example:
from langgraph.graph import MessageGraph, END

graph = MessageGraph()

def chatbot(state):
    response = llm.invoke(state)
    return [response]

graph.add_node("chatbot", chatbot)
graph.set_entry_point("chatbot")
graph.add_edge("chatbot", END)

app = graph.compile()
Note: Even for simple use cases, many developers now prefer StateGraph( MessagesState ) instead.

Migrating from MessageGraph to StateGraph

Migration is straightforward and highly recommended.
Step-by-step Migration:
# Before (MessageGraph)
from langgraph.graph import MessageGraph

graph = MessageGraph()
graph.add_node("agent", agent_node)
graph.set_entry_point("agent")
# After (StateGraph - Recommended)
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState

graph = StateGraph(MessagesState)   # ← Just change this

graph.add_node("agent", agent_node)
graph.add_edge(START, "agent")      # Use START instead of set_entry_point
graph.add_edge("agent", END)
Extra Fields (Biggest Benefit):
class AgentState(MessagesState):    # Extend MessagesState
    documents: list = []
    iterations: int = 0

graph = StateGraph(AgentState)
You can gradually add more fields without breaking existing logic.

Modern LangGraph Best Practices (2025+)

  • Prefer StateGraph(MessagesState) over MessageGraph
  • Always define your state using TypedDict or Pydantic
  • Use Annotated + reducers for all state fields
  • Keep custom state fields minimal and purposeful
  • Use Command for clean routing + state updates
  • Always add checkpointers (MemorySaver or persistent)
  • Implement safety limits in all cycles
  • Stream outputs (stream_mode="messages") for better UX
Recommended Base Template:
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from typing import TypedDict, Annotated

class AgentState(MessagesState):
    documents: list[dict] = []
    iterations: int = 0

graph = StateGraph(AgentState)
# ... add nodes and edges

Why Most Advanced Agents Use StateGraph

Modern production agents need more than just messages. They require:
  • Shared memory across multiple agents
  • Retrieved documents and context
  • Iteration tracking and safety controls
  • Supervisor / orchestrator logic
  • Persistent custom data (user profiles, preferences, etc.)
  • Complex routing and stateful decision making
StateGraph gives you the architectural flexibility needed for these sophisticated systems, while MessageGraph is mostly suited for simple conversational interfaces.
Bottom Line:
MessageGraph = Great for learning and simple bots.
StateGraph = The standard for serious, production-grade, agentic applications.

Common Mistakes

1. Using MessageGraph for Complex State

Mistake: Trying to force complex logic into MessageGraph by stuffing extra data into messages (e.g., JSON in content, metadata hacks, etc.).
Problem:
  • Hard to maintain
  • Loses type safety
  • Difficult to debug
  • Breaks when you need to scale
Bad Example:
def agent_node(state):  # state = list[Message]
    # Bad: stuffing data into message content
    data = {"documents": [...], "user_id": 123}
    response = llm.invoke(state + [HumanMessage(content=str(data))])
    return [response]
Better: Switch to StateGraph with proper state.

2. Overengineering Simple Chatbots with StateGraph

Mistake: Using a complex custom StateGraph with many fields for a basic chatbot that only needs conversation. Problem:
  • Unnecessary boilerplate
  • Harder to read and maintain
  • Overkill for the use case
When this happens: Building a simple customer support bot with iterations, confidence, documents, user_profile, etc., when a basic flow would suffice.
Rule of Thumb: If your graph only moves messages around → consider MessageGraph or StateGraph( MessagesState ).

3. Mixing Message State Incorrectly

Mistake: Incorrectly handling the messages field when using MessagesState.
# Bad
def bad_node(state):
    return {"messages": state["messages"] + [new_message]}  # Manual concat

# Also Bad
return [new_message]  # Forgets previous messages
Correct Way:
from langgraph.graph.message import add_messages

def good_node(state):
    return {"messages": [new_message]}   # add_messages reducer handles appending

4. Poor State Design

Common issues:
  • Putting too many unrelated fields in state
  • Not using reducers for lists/dicts
  • Mutable default values
  • No clear ownership of state fields
Bad Example:
class BadState(TypedDict):
    data: dict                    # Too vague
    everything: list              # No reducer
    temp: str = ""                # Mutable default

Best Practices

1. Keep State Minimal

Only store what you actually need during graph execution.
Good Example:
class AgentState(MessagesState):        # Inherit from MessagesState
    documents: list[dict] = []          # Only when doing RAG
    iterations: int = 0                 # For safety
    # Avoid: user_profile, full_db_results, etc.

Tip: If a piece of data is only used in one node, consider passing it via message metadata instead of top-level state

2. Use Typed State

Always define your state with type hints.
Recommended (Pydantic):
from pydantic import BaseModel, Field
from typing import Annotated
from langgraph.graph.message import add_messages

class AgentState(BaseModel):
    messages: Annotated[list, add_messages] = Field(default_factory=list)
    documents: list[dict] = Field(default_factory=list)
    iterations: int = Field(default=0)
    confidence: float = Field(default=0.0)

Or TypedDict (lighter):

from typing import TypedDict, Annotated

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    documents: Annotated[list[dict], lambda a, b: a + b]
    iterations: int

3. Separate Messages from Metadata

Keep conversation history clean and put auxiliary data in separate fields.
Good Design:
class AgentState(MessagesState):
    messages: Annotated[list, add_messages]
    
    # Metadata / Control fields
    documents: list[dict] = []
    iterations: int = 0
    current_task: str | None = None
    user_preferences: dict = {}
This makes debugging, streaming, and memory management much cleaner.

4. Design Clear State Flows

Think of your state as a contract between nodes.
Best Practices:
  • Document what each field means
  • Decide which nodes are allowed to write to which fields
  • Use clear field names (retrieved_docs instead of data)
  • Keep state flat when possible
Example State Design Comment:
class AgentState(MessagesState):
    messages: Annotated[list, add_messages]      # Conversation history
    retrieved_docs: list[dict]                   # From retriever node only
    iterations: int                              # Updated in agent node
    final_answer: str | None = None              # Written only at the end
Good state design is the foundation of maintainable LangGraph applications.
A well-designed state makes routing, debugging, persistence, and scaling significantly easier.

AI agent LangChain LangGraph Python

← All training