Skip to main content

Command Palette

Search for a command to run...

State Management in LangGraph

Updated
8 min read
State Management in LangGraph
G

Backend Engineer & AI/ML Developer passionate about building scalable APIs, cloud systems, and LLM-powered applications. Sharing insights on Python, Django, FastAPI, LangChain, and deploying AI in production. I love writing about: Backend Engineering: Python, Django, FastAPI, REST APIs, Celery, PostgreSQL, AWS, Docker AI/ML Applications: LLMs, LangChain, Prompt Engineering, NLP, Vector Databases, MLOps Scaling Products: Payment integration, asynchronous systems, and performance optimization On this blog, I’ll share lessons learned, tutorials, and real-world case studies from my journey building production-ready backends and AI applications. My goal is to make complex concepts practical, actionable, and beginner-friendly — especially for engineers looking to move from theory to real-world deployment.

In Part 1, we built a LangGraph agent with a single State field , a list of messages with an add_messages reducer , and watched it handle one tool call for a events query.

Now , say we want to push a little slightly: a user sends one message asking for events in Lagos, the current weather there, and a quick web search for upcoming festivals. The agent needs to call three tools across three loop iterations before it can respond. That's when state management becomes the thing you have to actually think about.

This article is about what happens inside State during that sequence ; how messages accumulate, what a reducer actually does at each step, when the single-field approach is enough, and when you need more.

A recap: State is the graph's memory

Every node in a LangGraph application reads from and writes to a shared State object. It's not passed by reference, LangGraph takes your node's returned dict, runs each field through its reducer, and produces a new state snapshot for the next node.

The City Events Agent's State from Part 1:

# agent/state.py
 
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
 
class State(TypedDict):
    messages: Annotated[list, add_messages]

One field. One reducer. That's the entire state definition. Let's understand exactly what add_messages does before we follow a real multi-hop query through it.

What add_messages actually does

A reducer is just a function that takes two arguments — the current field value and the new value returned by a node — and returns a merged result add_messages is LangGraph's built-in reducer for message lists. Its behaviour is straightforward but has a few details worth knowing:

  • Appending, not replacing

When a node returns {"messages": [new_message]}, the reducer appends new_message to the existing list rather than replacing it. This is the core behaviour. Without it, every node write would wipe out the entire conversation history.

# What the node returns:
{"messages": [AIMessage(content="Here are events in Lagos...")]}   

# What add_messages produces (appended to existing list):
# state["messages"] now contains ALL prior messages + this new one
  • Message deduplication by ID

Every LangChain message object carries a unique id field. If add_messages receives a message whose ID already exists in the list, it updates that message in place rather than appending a duplicate. This matters for streaming scenarios where the same message might be written incrementally, but for the standard invoke pattern we're using, you won't hit it.

  • Type coercion

add_messages also handles dict-to-message coercion. When we invoke the graph like this:

graph.invoke(
    {"messages": [{"role": "user", "content": "Events in Lagos, weather, and festivals?"}]}
)

That raw dict gets converted to a proper HumanMessage object automatically. You can pass dicts or message objects, the reducer normalises them.

You rarely need to think about these details day-to-day. But when you hit a bug where messages are mysteriously duplicated or overwritten, this is where to look first.

State across a multi-hop query

Let's walk through what State actually looks like at each step of a query that hits all three tools. The user sends:

"What events are on in Lagos this weekend, what's the weather like, and find me any upcoming festivals in the city?"

The LLM decides to call all three tools. Depending on the model, it might call them in sequence (one per loop) or batch them in a single response. Here's the sequential path, which is the most common:

AFTER THE USER MESSAGE — STATE SNAPSHOT 1

messages: [
  HumanMessage(content="What events are on in Lagos this weekend,
               what's the weather like, and find me any upcoming festivals?")
]

The chatbot node calls the LLM with this. The LLM decides to call events_database_tool first and returns an AI message containing a tool call.

AFTER CHATBOT NODE, LOOP 1 — STATE SNAPSHOT 2

messages: [
  HumanMessage(...),                          # user question
  AIMessage(tool_calls=[{                     # LLM's decision
    "name": "events_database_tool",
    "args": {"city": "Lagos"},
    "id": "call_abc123"
  }])
]

tools_condition sees the tool_calls field on the AI message and routes to the tools node. ToolNode executes events_database_tool("Lagos") and appends the result: 

AFTER TOOLS NODE, LOOP 1 — STATE SNAPSHOT 3

messages: [ HumanMessage(...), AIMessage(tool_calls=[{name: "events_database_tool", ...}]), ToolMessage(  content='{"events": [...], "count": 4}', tool_call_id="call_abc123" ) ]

Control returns to chatbot. The LLM now sees the full history — original question plus the events result and decides to call weather_tool next. The cycle repeats twice more.

FINAL STATE — AFTER ALL THREE TOOL CALLS RESOLVED

messages: [ HumanMessage(...),AIMessage(tool_calls=[events_database_tool]), ToolMessage(content='{"events": [...]}'),  AIMessage(tool_calls=[weather_tool]), ToolMessage(content='{"weather": {...}}'), AIMessage(tool_calls=[search_tool]), ToolMessage(content='{"results": [...]}'),  AIMessage(content="Here's what I found...") # final response ]

All of messages accessible to the LLM on its final call, which is why it can weave together events, weather, and festival results into one coherent response.

What happens when a tool fails

Tools fail. A database file is missing, an API key is invalid, a network request times out. The City Events Agent's tools all handle this explicitly, they return a JSON error string rather than raising an exception

# agent/tools.py (excerpt)

@tool
def events_database_tool(city: str) -> str:
    if not os.path.exists(DB_PATH):
        return json.dumps({"error": f"Database not found at {DB_PATH}"})
    try:
        # ... query logic ...
    except Exception as exc:
        return json.dumps({"error": str(exc)})

This is a deliberate design choice. When a tool returns an error string, ToolNode treats it as a successful execution and appends it to State as a normal ToolMessage. The LLM then sees the error in its next invocation and can decide how to respond: retry with different arguments, tell the user the tool is unavailable, or proceed with the other tools' results.

The alternative, which is letting exceptions propagate — causes ToolNode to raise and halt graph execution entirely. For a conversational agent, that's rarely what you want. Returning structured error JSON keeps the loop running and puts the recovery decision with the LLM where it belongs.

# State after a failed database lookup still looks like this:
ToolMessage(
  content='{"error": "Database not found at /data/local_info.db"}',
  tool_call_id="call_abc123"
)
# The LLM reads this, understands the tool failed, and responds accordingly.

When one field isn't enough The City Events Agent gets away with a single messages field. Not every agent will. Here's how to think about when to extend State. The single-field approach works when everything your agent needs is already in the message history. Tool results, prior decisions, user preferences expressed in the conversation ,if the LLM can read it from the accumulated messages, you don't need a separate state field for it. The City Events Agent fits this profile cleanly.

You need additional fields when You have structured data that isn't for the LLM. Tracking the number of retries on a failing tool, a user ID for personalisation, or a session start timestamp — none of these belong in the message list, but they might drive conditional logic in your nodes.

You need to enforce limits. Say you want to cap tool calls at five per conversation to control costs. The cleanest way is a dedicated counter field:

# Extended State example — not used in this project,
# but how you'd approach it

class State(TypedDict):
    messages: Annotated[list, add_messages]
    tool_call_count: int # no reducer = last-write-wins

A field with no reducer uses last-write-wins semantics, whichever node writes to it last wins. For a simple counter that only one node ever increments, that's fine. For a field that multiple nodes might write concurrently, you'd want a custom reducer. You're building multi-agent systems. When multiple sub-agents share a graph, you often need fields to track which agent is currently active, what the handoff payload is, or what the overall task status is. But that's well beyond the scope of this series. The rule of thumb Start with just messages. Add a new field only when you find yourself trying to infer something from the message history that would be cleaner as an explicit value.

The one mistake that's easy to make

writing to a State field that has no reducer from multiple nodes. Suppose you added a last_tool_used string field to State and both the chatbot node and the tools node write to it. Without a reducer, LangGraph resolves this with last-write-wins. In a sequential graph like ours, that's deterministic. But if you ever parallelise node execution, two nodes writing the same field simultaneously will produce unpredictable results. The fix is always the same: define a reducer for any field more than one node might write.

# Custom reducer example — keeps only the most recent value,
# but makes the intent explicit
def keep_latest(current: str, update: str) -> str:
    return update

class State(TypedDict):
    messages: Annotated[list, add_messages]
    last_tool_used: Annotated[str, keep_latest]

For the City Events Agent as built, this isn't a concern — we have one field, one reducer, sequential execution. But knowing the pattern matters when you extend it.

What's next

Part 3 is where we pay more attention to the tools themselves . How they're structured, why the @tool decorator and docstring quality directly affects the LLM's tool selection, and what th Tavily and OpenWeatherMap integrations actually look like under the hood. find the entire repo here: https://github.com/GloryKO/LangGraph-Series