How to Fix 'state not updating during development' in LangGraph (Python)
Opening
If your LangGraph app says state is not updating during development, the graph is usually running, but the state you expect to change is either being overwritten, never returned, or being read from the wrong place. This tends to show up when you add a node, run the graph in a loop, and keep seeing the same StateGraph output or stale values in graph.stream() / graph.invoke().
The most common symptom is that your node executes, but downstream nodes still receive the old state. In practice, this is almost always a state schema or return-shape problem, not a LangGraph runtime bug.
The Most Common Cause
The #1 cause is returning the wrong shape from a node. In LangGraph, nodes must return a partial state update as a dictionary keyed by your state fields. If you return a raw string, an AI message object in the wrong format, or mutate local variables without returning them, LangGraph has nothing to merge into the graph state.
Here’s the broken pattern:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
messages: list[str]
counter: int
def increment_counter(state: State):
# Wrong: mutating local copy and returning nothing useful
state["counter"] += 1
print("counter:", state["counter"])
return "done" # Wrong shape
builder = StateGraph(State)
builder.add_node("increment_counter", increment_counter)
builder.add_edge(START, "increment_counter")
builder.add_edge("increment_counter", END)
graph = builder.compile()
result = graph.invoke({"messages": [], "counter": 0})
print(result)
And here’s the fixed version:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
messages: list[str]
counter: int
def increment_counter(state: State):
# Right: return a partial state update
return {"counter": state["counter"] + 1}
builder = StateGraph(State)
builder.add_node("increment_counter", increment_counter)
builder.add_edge(START, "increment_counter")
builder.add_edge("increment_counter", END)
graph = builder.compile()
result = graph.invoke({"messages": [], "counter": 0})
print(result) # {'messages': [], 'counter': 1}
The rule is simple:
- •If you want LangGraph to persist it in state, return it from the node.
- •If you only print or mutate a local object without returning the update, downstream nodes will not see it.
- •If your node returns a non-dict value, you’ll often get errors like:
- •
InvalidUpdateError: Expected dict-like update - •
TypeError: Expected mapping type for state update
- •
Other Possible Causes
1) Your reducer overwrites updates
If two nodes write to the same field and you did not define a reducer correctly, one update can replace another. This shows up a lot with message lists.
Broken:
from typing_extensions import Annotated
from typing import TypedDict
from langgraph.graph import StateGraph
import operator
class State(TypedDict):
messages: list[str] # no reducer here
def node_a(state: State):
return {"messages": ["A"]}
def node_b(state: State):
return {"messages": ["B"]}
Fixed:
from typing_extensions import Annotated
from typing import TypedDict
import operator
class State(TypedDict):
messages: Annotated[list[str], operator.add]
For chat apps, use LangGraph’s message reducer pattern instead of plain lists when possible.
2) You are using MessageGraph / MessagesState incorrectly
If you’re building an agent and using message objects directly, mixing plain dicts and BaseMessage objects can make it look like state is not updating.
Broken:
def assistant_node(state):
return {"messages": [{"role": "assistant", "content": "hi"}]}
Fixed:
from langchain_core.messages import AIMessage
def assistant_node(state):
return {"messages": [AIMessage(content="hi")]}
If your graph expects MessagesState, keep message types consistent across all nodes.
3) You compiled an old graph object during development
A very common dev-time issue is editing node logic but still calling an older compiled instance. In notebooks and long-running processes this happens constantly.
Broken:
graph = builder.compile()
# later you change node code above,
# but keep reusing the old compiled graph instance
result = graph.invoke(input_state)
Fixed:
# rebuild after code changes
builder = build_graph()
graph = builder.compile()
result = graph.invoke(input_state)
If you are using hot reload or Jupyter, restart the kernel when behavior looks impossible.
4) Your checkpointer makes it look stale
When using persistence with MemorySaver, SQLite checkpointers, or thread-based execution, you may be reading an old thread’s last checkpoint instead of fresh input.
Config example:
config = {"configurable": {"thread_id": "dev-1"}}
result = graph.invoke({"messages": []}, config=config)
If that same thread_id is reused across tests or manual runs, you will see previous state again.
Fix:
- •Use a unique
thread_idper test run. - •Clear checkpoints between experiments.
- •Confirm whether you want resumable state or stateless runs.
How to Debug It
- •
Print exactly what each node returns
- •Add logging inside every node.
- •Verify each node returns a dict with valid keys from your schema.
- •If you see strings like
"done"orNone, that’s your bug.
- •
Inspect the final merged output
- •Run with
graph.invoke(...)first. - •Then compare with
graph.stream(...)if needed. - •If stream events show updates but final state does not change as expected, look at reducers and key names.
- •Run with
- •
Check your state schema
- •Make sure every field updated by nodes exists in the schema.
- •For append-style fields like messages or history arrays, use reducers such as
operator.add. - •If using typed message states, keep message classes consistent.
- •
Eliminate persistence
- •Temporarily remove checkpointers and pass fresh input only.
- •If the bug disappears, your issue is thread reuse or stale checkpoint data.
- •Reintroduce persistence after confirming pure graph behavior works.
Prevention
- •
Return partial updates only:
- •Each node should return
{field_name: new_value}. - •Do not rely on mutation alone.
- •Each node should return
- •
Define reducers for shared fields:
- •Use reducers for lists and append-only histories.
- •Avoid silent overwrites when multiple nodes write to one key.
- •
Keep dev runs isolated:
- •Use unique
thread_idvalues during testing. - •Rebuild and recompile graphs after code changes instead of reusing old instances.
- •Use unique
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
By Cyprian Aarons, AI Consultant at Topiax.
Want the complete 8-step roadmap?
Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.
Get the Starter Kit