Home / Articles / Six LangGraph Primitives and the Failure Mode Hiding in Each One

This article is published in English.

Six LangGraph Primitives and the Failure Mode Hiding in Each One

Learn LangGraph's state, nodes, edges, conditional routing, checkpointing and interrupts through the specific bugs each one invites and how to avoid them.

2647 words

A first LangGraph agent usually comes together fast: it answers a question, refines the answer and stops. Then someone adds a retry branch, and suddenly the graph no longer terminates, burning API credits until the process is killed. The fix is often a single missing edge, but the real problem is the lack of a mental model for why the graph behaves the way it does.

This guide builds that model from the six primitives LangGraph is made of: state, nodes, direct edges, conditional edges, checkpointing and human-in-the-loop. For each one you will see a minimal example, the mistake teams most often make with it, and the version worth shipping. If you want a broader tour of agent patterns built on these pieces, LangGraph in practice: state, nodes, edges and five agent patterns covers that ground; here the focus is on failure modes.

Why a graph instead of a chain

LangChain's pipe syntax, prompt | llm | parser, is pleasant for a single pass through a model. It stops fitting as soon as the agent has to decide something: search or answer directly, retry or give up, ask a person or continue. A chain has no notion of "it depends", so developers wrap chain calls in if statements, and before long they have hand-built an undocumented, harder-to-debug state machine.

LangGraph makes that state machine explicit. You get nodes, edges and one shared state object you can inspect at any point. There is nothing magical about it, and that is precisely the advantage: every decision the agent makes corresponds to something you can read in the graph definition.

1. State: one shared object, and reducers that matter

State is the single object that every node reads from and writes to. Without it, context tends to get passed around as function arguments, and it becomes hard to say what any given step actually knew. The definition below is a TypedDict with a question, an answer and a message list whose updates are merged by the add_messages reducer.

from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
    question: str
    answer: str
    messages: Annotated[list, add_messages]

operator.add is not a message reducer

Many tutorials annotate the messages field with operator.add instead. It looks right: add appends to the list rather than overwriting it, which is what a growing conversation needs. The catch is that it concatenates blindly. As soon as you need to update or remove an existing message, for example when trimming history or replacing a tool-call result, it appends a duplicate instead, and the conversation history fills with stale entries without any error.

add_messages is purpose-built for this. It matches messages by ID and replaces an existing message in place when the ID is already present, appending only genuinely new ones. The rule is simple: use add_messages for fields holding HumanMessage and AIMessage objects, and keep operator.add for plain accumulating lists, such as a running record of which tools were called.

Keep the state shape minimal

The second common mistake is designing state like a database schema, with a field for every need someone might have later. Add a field only when a node actually reads or writes it. The cost of ignoring this is concrete: consider a document-processing graph that stores complete raw LLM responses, token-usage metadata included, in state. Processing 50 documents in a loop pushed each checkpoint to about 180KB, and Postgres writes rose above 400ms, slow enough for users waiting on a response to notice. The fix was unglamorous: reduce state to the three fields downstream nodes really used. Remember that with a checkpointer attached, everything in state is serialized and saved at every step.

2. Nodes: return only what changed

A node is an ordinary Python function. It receives the state, does its work and returns a dictionary containing only the fields it changed. That is the whole contract. The first example calls an OpenAI chat model with the question and writes the reply into answer.

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(model="gpt-4o-mini")
def answer_node(state: AgentState) -> dict:
    response = llm.invoke([HumanMessage(content=state["question"])])
    return {"answer": response.content}

While you are iterating on graph structure, which is most of the early work, you may not want every draft to hit a paid API. A local model served by Ollama implements the same interface, so the node body stays identical and debugging costs nothing:

from langchain_ollama import ChatOllama
llm = ChatOllama(model="llama3.1", temperature=0)
def answer_node(state: AgentState) -> dict:
    response = llm.invoke([HumanMessage(content=state["question"])])
    return {"answer": response.content}

This version needs Ollama running locally with the model pulled (ollama pull llama3.1) and the integration package installed (pip install langchain-ollama). Setting temperature=0 also makes runs more repeatable, which helps when you are testing routing logic.

Returning the whole state clobbers other updates

A frequent bug is returning the entire state dictionary from a node rather than just the changed keys. In a small linear graph this appears to work, because nothing else touches those fields. Once two nodes update overlapping fields, one node's full return overwrites the other's changes with old values. The symptom looks like a routing problem, so developers tend to hunt through edge logic when the actual cause is a node returning too much. Returning a minimal update also lets reducers do their job: a field without a reducer is simply replaced by whatever the node returns.

3. Direct edges: always wire the exit

Edges decide what runs next. A direct edge is unconditional: when node A finishes, node B runs. The graph below registers two nodes, connects answer to refine, connects refine to END, sets the entry point and compiles.

from langgraph.graph import StateGraph, END
graph = StateGraph(AgentState)
graph.add_node("answer", answer_node)
graph.add_node("refine", refine_node)
graph.add_edge("answer", "refine")
graph.add_edge("refine", END)
graph.set_entry_point("answer")
app = graph.compile()

The END edge is the part people forget, and it is the classic source of a graph that seems to run forever. The fully reliable habit is to make every path through the graph end at END explicitly, so you can trace termination by reading the definition. This matters most once cycles appear: a retry loop with no path to END, or with a condition that never becomes true, keeps cycling until LangGraph's recursion limit stops it with a GraphRecursionError. That limit is a safety net, not a design; each of those iterations still costs tokens. When a graph appears hung, check the graph definition before anything else.

4. Conditional edges: where the agent actually decides

Conditional edges are what make the graph an agent rather than a fixed pipeline. A routing function inspects the state and returns a label; a mapping translates each label into the next node. In this example, a short answer (under 50 characters) is sent to refine, and anything else goes to END.

def route_based_on_quality(state: AgentState) -> str:
    if len(state["answer"]) < 50:
        return "refine"
    return "done"
graph.add_conditional_edges(
    "answer",
    route_based_on_quality,
    {"refine": "refine", "done": END},
)

Note that this conditional edge replaces the direct answer to refine edge from the previous snippet. If you register both, both paths are taken, which is rarely what you want.

Mismatched route labels fail loudly but obscurely

The recurring mistake here is a routing function that returns a string not present in the mapping dictionary. The resulting error is a fairly generic key error buried several layers deep in the stack trace, and something as small as a trailing space can cost a surprising amount of time. A reliable habit: write the mapping first, then write the router by copying the exact keys from it. Better still, define the labels once as constants or annotate the router's return type with Literal["refine", "done"] so type checkers and readers see the allowed values immediately.

5. Checkpointing: memory that survives between calls

A checkpointer turns a stateless function call into a conversation with memory. Without one, every app.invoke() starts from scratch. With one, state is saved per thread, and any call that passes the same thread_id in its config continues where the previous one stopped. In the example, the second invocation on thread user-session-42 remembers the first question.

from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "user-session-42"}}
app.invoke({"question": "What is LangGraph?"}, config)
app.invoke({"question": "Show me a code example"}, config)   # remembers the first turn

InMemorySaver is appropriate for local development and nothing else. It lives in process memory, so a server restart erases every conversation. Anything real users depend on needs a persistent backend: SQLite for a single server, or Postgres when several instances must share state.

# single-server production — pip install langgraph-checkpoint-sqlite
from langgraph.checkpoint.sqlite import SqliteSaver
# multi-instance production, needs shared state across servers
# pip install langgraph-checkpoint-postgres
from langgraph.checkpoint.postgres import PostgresSaver

Each backend ships as its own package, as the install comments show. In current versions these savers are typically created from a connection string (for example via from_conn_string) and Postgres needs a one-time setup() call to create its tables, so check the checkpointer docs for the exact initialization in your version.

The failure mode here is using the in-memory saver in production and learning about it when a staging restart wipes a live demo. The good news is that switching is cheap if the graph is otherwise well built: the checkpointer is a compile-time argument, not a redesign, and moving to SqliteSaver can take well under an hour.

6. Human-in-the-loop: static breakpoints versus dynamic interrupts

The pattern most tutorials show is interrupt_before, a list of node names where the compiled graph pauses before executing:

app = graph.compile(
    checkpointer=checkpointer,
    interrupt_before=["send_email"],
)

It works and is easy to explain, but it is static. The pause point is fixed by node name, you cannot make it conditional, and you cannot attach a payload describing what the reviewer should look at. Real requirements outgrow it quickly, because "pause before this node" and "pause only when the refund exceeds $500" are different rules, and only the first can be expressed this way.

Pausing from inside the node with interrupt()

The more flexible pattern is calling interrupt() from within the node. The node below checks the refund amount; above $500 it pauses and surfaces the draft and amount to a human. The first invoke runs until that pause. The second call passes Command(resume="approve") on the same thread, and the value supplied to resume becomes the return value of interrupt(), so the node either proceeds to send or returns a cancelled status. A checkpointer is required, because the paused state has to be stored somewhere while it waits.

from langgraph.types import interrupt, Command
def send_email_node(state: AgentState) -> dict:
    if state["refund_amount"] > 500:
        decision = interrupt({
            "draft": state["draft"],
            "amount": state["refund_amount"],
        })
        if decision != "approve":
            return {"status": "cancelled"}
    # send the email
    return {"status": "sent"}
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "task-99"}}
app.invoke({"task": "Draft and send a refund email"}, config)
# graph pauses inside send_email_node, surfaces the interrupt payload
app.invoke(Command(resume="approve"), config)

The example state uses fields such as refund_amount, draft and task that are not in the earlier AgentState; in a real graph you would declare them there.

Resuming re-runs the whole node

The behavior that surprises people: on resume, LangGraph does not continue from the interrupt() line. It re-executes the entire node from the top, and this time interrupt() returns the resume value instead of pausing. Any code before the call runs again. A node that increments a counter before interrupting will increment it twice for every approval. Keep everything before an interrupt() idempotent, or move side effects into an earlier node. The same reasoning applies to API calls or database writes placed ahead of the pause.

A model approving itself is not human-in-the-loop

Whichever mechanism you choose, asking the model "should I proceed?" and trusting the reply is not human oversight, however it is labeled. It is the agent signing off on its own decision. A genuine approval step hands control to a person outside the graph and waits for their answer.

The six primitives at a glance

The summary below pairs each concept with what it does and the typical mistake associated with it.

+----------------------+----------------------------------------+---------------------------+
| Concept              | What it does                            | The mistake I made        |
+----------------------+----------------------------------------+---------------------------+
| State                | Shared, typed dict every node touches   | operator.add instead of   |
|                      |                                          | add_messages for chat     |
+----------------------+----------------------------------------+---------------------------+
| Nodes                | Plain functions: state in, updates out  | Returning full state,     |
|                      |                                          | not just changed fields   |
+----------------------+----------------------------------------+---------------------------+
| Direct edges         | Always go to the same next node         | Forgetting to wire END    |
+----------------------+----------------------------------------+---------------------------+
| Conditional edges    | Function inspects state, picks next node| Return value doesn't      |
|                      |                                          | match a mapping key       |
+----------------------+----------------------------------------+---------------------------+
| Checkpointing        | Persists state per thread_id            | InMemorySaver in prod     |
+----------------------+----------------------------------------+---------------------------+
| Human-in-the-loop    | Pauses for a real person, then resumes  | Non-idempotent code       |
|                      |                                          | before interrupt()        |
+----------------------+----------------------------------------+---------------------------+

A sensible build order

For a first real graph, get the complete loop working end to end with InMemorySaver and no interrupts. Keep the state small and limited to what nodes need, and confirm that every conditional edge returns exactly the labels its mapping expects. Only once that runs cleanly should you swap in a persistent checkpointer and add an interrupt at the one step that genuinely requires a person, typically anything that moves money, sends an external email or deletes data.

More advanced features, including custom reducers beyond add_messages, subgraphs that split a large graph into testable parts, and token-level streaming, all sit on the same skeleton. They are much easier to adopt after you have built, broken and fixed a graph using only these six ideas.

Key takeaways

  • Use add_messages for chat history and operator.add only for plain lists, and keep state lean because it is persisted at every step.
  • Return only changed fields from nodes; full-state returns silently overwrite parallel or earlier updates.
  • Give every path an explicit route to END, and bound retry loops rather than relying on the recursion limit.
  • Derive routing labels from the mapping so they cannot drift apart.
  • Treat InMemorySaver as development-only; the checkpointer swap is cheap, so do it before users depend on the graph.
  • Prefer dynamic interrupt() for conditional approvals, and keep code before it idempotent because resuming re-runs the node.

Reference documentation: the Graph API documentation for LangGraph, the interrupts guide and the interrupt() API reference.