LangGraph MessagesState — Append vs Replace Pitfalls — Fix Guide (2026)
State Management · MessagesState Severity: High

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.

TL;DRMessagesState 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

Duplicated system messages
Duplicated system messages
# 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.
BadRequestError from context overflow
BadRequestError from context overflow
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.
Edit did not persist
Edit did not persist
# 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

ReturnEffect
[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 returnBug — 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].content in 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 MessagesState when you need additional fields. MessagesState has only the messages key — trying to update anything else raises InvalidUpdateError. Subclass it.

How to fix it

Fix #1

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.

system_prompt_once.pypython
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"]),
    ]}
Note: For prompts that vary per turn (e.g. "You are answering question #42"), don't use SystemMessage at all — put the dynamic instruction into the HumanMessage or a runnable prompt template. SystemMessage is for stable role setup, once per session.
Fix #2

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.

trim_before_llm.pypython
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)
Note: 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).
Fix #3

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.

edit_and_delete.pypython
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 {}
Note: The 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 id so add_messages dedupes 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 id to edit; use RemoveMessage to 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 RemoveMessage tombstones 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

What's the difference between MessagesState and a custom TypedDict with add_messages?

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.

Can I subclass MessagesState to add fields?

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.

How do I keep a long-running chat thread bounded?

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.

Does trim_messages modify state?

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.

Why do parallel invocations on the same thread_id lose messages?

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.

Related errors