LangGraph MessagesState Append vs Replace Pitfalls
MessagesState is the convenience shortcut for chat graphs, but it hides two traps that bite in production: a system prompt that duplicates across turns, and a history that grows until the model rejects it. Here's how add_messages actually decides to append vs replace, and the three patterns that keep long chat sessions stable.
By Sana K. · Last updated Aug 14, 2026 · LangGraph · Page #126
MessagesState uses add_messages under the hood: it appends new messages, replaces messages with matching id, and removes messages via RemoveMessage(id=...). The three common failures: (1) prepending a system prompt inside every node so it duplicates, (2) editing messages by mutating the list instead of returning a new message with the same ID, and (3) letting history grow unbounded — fix with trim_messages before the LLM call.Real error messages you'll see
# Symptom — messages list grows a system prompt every turn
# [SystemMessage("You are helpful."), HumanMessage(...), AIMessage(...),
# SystemMessage("You are helpful."), HumanMessage(...), AIMessage(...), ← dup
# SystemMessage("You are helpful."), HumanMessage(...), AIMessage(...)] ← dup
# Root cause: prepending SystemMessage inside a node without an ID; add_messages appends every time.
anthropic.BadRequestError: Error code: 400 - {"type": "invalid_request_error",
"message": "prompt is too long: 210341 tokens > 200000 maximum"}
# LangGraph never trimmed the messages list. Every turn the whole history is sent to the model. Fix: trim_messages before the LLM call inside the node.
# Symptom — you returned a "corrected" message but the original is still in state.
def edit(state):
prior = state["messages"][-1]
prior.content = "corrected" # ← mutation, not a return
return {} # nothing to merge, edit is lost on checkpoint replay
# Fix: return a new message with the same id via add_messages replace semantics.
add_messages semantics reference
| Return | Effect |
|---|---|
[AIMessage("hi")] | Append — new UUID, added at end |
[AIMessage("edit", id=prior.id)] | Replace — matches by ID, swaps content in place |
[RemoveMessage(id=x)] | Delete — tombstone; removes the message from state |
[AIMessage("a"), AIMessage("b")] | Both appended in order |
[] | No-op — no messages added or changed |
state["messages"] + [new_msg] as return | Bug — appends the ENTIRE existing history again, duplicating it |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 25%Prepending a system prompt inside a node. Node returns
{"messages": [SystemMessage("..."), *state["messages"]]}. add_messages doesn't special-case system messages — they duplicate every turn. - 18%History never trimmed. Long-running chat threads accumulate messages until they exceed the model's context window. Every turn is more expensive and eventually fails with 400.
- 14%Editing via mutation instead of replacement. Modifying
state["messages"][-1].contentin place. LangGraph doesn't detect the mutation — checkpoint replay restores the original. - 12%Returning
state["messages"] + [new]. Whole history gets appended to itself, doubling in size every turn. - 10%Custom message class without an
id. Deduplication silently fails; identical messages accumulate on retries and parallel branches. - 9%Tool messages missing
tool_call_id. API returns 400 because tool responses can't be linked to their call. Not a MessagesState error per se, but almost always misdiagnosed as one. - 7%Concurrent turns overwriting each other. Same thread ID invoked twice in parallel. Second invocation loads a stale checkpoint and its updates overwrite the first. Serialize per-thread requests.
- 5%Using
MessagesStatewhen you need additional fields.MessagesStatehas only themessageskey — trying to update anything else raisesInvalidUpdateError. Subclass it.
How to fix it
Set the system prompt once — at invocation, not inside a node
Fixes duplicated SystemMessage entries.
The system prompt belongs in the initial state passed to app.invoke, not inside a node. A node that runs on every turn will re-append the system message every turn. If you truly need node-side injection, give the system message a stable ID so add_messages can dedupe it.
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langgraph.graph import StateGraph, START, END, MessagesState
def responder(state: MessagesState) -> dict:
reply = llm.invoke(state["messages"])
return {"messages": [reply]}
graph = StateGraph(MessagesState)
graph.add_node("responder", responder)
graph.add_edge(START, "responder")
graph.add_edge("responder", END)
app = graph.compile(checkpointer=memory)
# ✅ System prompt is provided ONCE, as part of initial invocation
config = {"configurable": {"thread_id": "chat-1"}}
result = app.invoke(
{"messages": [
SystemMessage(content="You are a concise expert on LangGraph."),
HumanMessage(content="What is a StateGraph?"),
]},
config,
)
# On subsequent turns, ONLY append the new user message.
# The SystemMessage is already in state from turn 1.
result = app.invoke(
{"messages": [HumanMessage(content="And what is a reducer?")]},
config,
)
# ✅ ALTERNATIVE — inject with stable ID so dedupe works
def responder_with_system(state):
system = SystemMessage(content="You are a concise expert.", id="sys-1")
# add_messages will dedupe on id="sys-1" — appears once even if returned each turn
return {"messages": [system, llm.invoke([system] + state["messages"])]}
# ❌ ANTI-PATTERN — no ID, duplicates every turn
def bad(state):
return {"messages": [
SystemMessage(content="You are..."), # new UUID every call
llm.invoke(state["messages"]),
]}
Trim messages before every LLM call — cap tokens explicitly
Fixes BadRequestError: prompt is too long.
Long-running chat threads accumulate messages indefinitely. Even a 200K context window fills up after a few dozen exchanges with retrieval attachments. Use LangChain's trim_messages inside your responder node to cap the input to the model. State keeps the full history; only what the LLM sees is trimmed.
from langchain_core.messages import (
SystemMessage, HumanMessage, AIMessage, trim_messages,
)
from langgraph.graph import StateGraph, START, END, MessagesState
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-opus-4-7", temperature=0.2)
def responder(state: MessagesState) -> dict:
# trim to fit — SystemMessage kept, oldest human/ai messages dropped first
trimmed = trim_messages(
state["messages"],
max_tokens=180_000, # leave headroom for the response
token_counter=llm,
strategy="last", # keep the most recent messages
include_system=True, # never drop the SystemMessage
allow_partial=False, # never split a message in half
start_on="human", # first non-system message must be human
)
reply = llm.invoke(trimmed)
return {"messages": [reply]} # append the reply to FULL history
# state["messages"] still contains everything — the LLM just sees the trimmed slice
# This gives you: full audit trail in state + safe LLM input + no 400s
# ✅ ALTERNATIVE — bounded history in state (aggressive)
from langchain_core.messages import RemoveMessage
def prune_old(state):
"""Keep only the last 40 messages IN STATE."""
msgs = state["messages"]
if len(msgs) <= 40:
return {}
# Tombstone the oldest overflow via RemoveMessage
overflow = msgs[:-40]
return {"messages": [RemoveMessage(id=m.id) for m in overflow]}
# Wire it as a node that runs after the responder
graph.add_node("prune", prune_old)
graph.add_edge("responder", "prune")
graph.add_edge("prune", END)
trim_messages counts tokens using the model as the token counter — this means an extra call to the model's tokenizer. If that adds latency, pass a cheap approximate counter: token_counter=lambda msgs: sum(len(m.content) for m in msgs) // 4 (rough 4-chars-per-token estimate).Edit messages by returning a new one with the same ID
Fixes edits that vanish on checkpoint replay.
Mutating a message in place — state["messages"][-1].content = "..." — appears to work in the current call but the change never lands in the checkpoint. On resume, the original content is back. The correct way is to return a new message with the same ID, which triggers add_messages's replace semantics.
from langchain_core.messages import AIMessage, RemoveMessage
from langgraph.graph import MessagesState
# ❌ BUG — mutation isn't persisted through the reducer
def bad_edit(state):
state["messages"][-1].content = "corrected content"
return {} # nothing in the update = reducer sees nothing = checkpoint keeps the original
# ✅ Return a replacement message with the SAME ID
def edit_last(state: MessagesState) -> dict:
prior = state["messages"][-1]
replacement = AIMessage(
content="corrected content",
id=prior.id, # <-- key: matching id triggers replace
)
return {"messages": [replacement]} # add_messages replaces in place
# ✅ Delete a message via tombstone
def delete_message_by_id(state, target_id: str) -> dict:
return {"messages": [RemoveMessage(id=target_id)]}
# ✅ Redact PII in the last user message (real production pattern)
import re
def redact_last(state):
prior = state["messages"][-1]
if prior.type != "human":
return {}
redacted = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", prior.content)
if redacted == prior.content:
return {} # no change, don't touch state
return {"messages": [HumanMessage(content=redacted, id=prior.id)]}
# ✅ Batch edit — multiple replacements in one return
def rewrite_all_ai(state):
updates = []
for m in state["messages"]:
if isinstance(m, AIMessage) and "TODO" in m.content:
updates.append(AIMessage(
content=m.content.replace("TODO", "[in progress]"),
id=m.id,
))
return {"messages": updates} if updates else {}
RemoveMessage tombstone only works with add_messages. If you've written a custom reducer, you need to handle RemoveMessage yourself — most people just use add_messages and get delete semantics for free.Prevention checklist
- Set the SystemMessage once — in the initial
app.invoke, not inside a node that runs every turn. - If a system prompt must be injected inside a node, give it a stable
idsoadd_messagesdedupes it. - Always trim messages before the LLM call with
trim_messages(max_tokens=...). Never rely on model failures to enforce limits. - Never mutate messages in place. Return a new message with the same
idto edit; useRemoveMessageto delete. - Never return
state["messages"] + [new]— that appends the entire prior history to itself. Just[new]; the reducer merges. - For long-lived threads, add a "prune" node that emits
RemoveMessagetombstones once history exceeds a bounded size. - Serialize concurrent turns per thread_id — LangGraph checkpoints are last-write-wins and parallel invocations to the same thread lose data.
Frequently asked questions
MessagesState is exactly a TypedDict with a single field: messages: Annotated[list[AnyMessage], add_messages]. That's the entire class. Use it when your graph only needs a message history and no other fields. The moment you need question, classification, retry_count, or anything else, subclass it or write your own TypedDict.
Yes — class MyState(MessagesState): question: str; retry_count: int. The subclass inherits the reducer-annotated messages field and adds your own. This is the cleanest pattern when you're starting from a chat graph and layering in additional bookkeeping. Nodes can then read and update both messages and the new fields.
Two layers: trim what the LLM sees (trim_messages inside the responder node), and optionally trim what state stores (a prune node that emits RemoveMessage tombstones). The first layer is mandatory — without it you'll hit context-window errors. The second is optional; only add it when checkpoint size becomes a storage or latency concern.
No. trim_messages returns a new list; the input list is unchanged. In practice you call it right before llm.invoke, use the trimmed list as the LLM input, and let the LLM's reply merge into full state via add_messages. State keeps the full history; only what crosses the model boundary is trimmed. This gives you a full audit trail and cheap LLM calls.
LangGraph checkpoints are last-write-wins per thread. Two concurrent app.invoke calls with the same thread_id both load the same starting checkpoint, run their nodes, and each write a new checkpoint. The second write clobbers the first. There's no built-in optimistic locking. Serialize per-thread work (Redis lock, a queue, or a per-thread lock in your web server) if concurrent access is possible.