How to Fix 'state not updating during development' in LangGraph (Python)

By Cyprian AaronsUpdated 2026-04-21
state-not-updating-during-developmentlanggraphpython

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_id per test run.
  • Clear checkpoints between experiments.
  • Confirm whether you want resumable state or stateless runs.

How to Debug It

  1. 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" or None, that’s your bug.
  2. 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.
  3. 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.
  4. 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.
  • 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_id values during testing.
    • Rebuild and recompile graphs after code changes instead of reusing old instances.

Keep learning

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

Related Guides