Ask a team why they moved from LangChain to LangGraph and you’ll usually get a shrug and “the agent kept doing weird stuff.” That answer is more accurate than it sounds, but it hides the actual mechanism. A AgentExecutor chain is a loop wearing a trench coat β it looks like it’s making decisions, but underneath it’s a fixed while-loop that calls a model, checks for a tool call, runs the tool, and repeats until the model stops asking for tools. There’s no way to pause it, no way to branch it, and no way to make it remember anything past what fits in the prompt on the next iteration.
That’s fine for a huge number of real use cases, and this is the part most “LangGraph is better” takes skip. The real question isn’t “which one is better” β it’s “does my control flow need to loop back on itself, fork into parallel branches, or survive a restart.” Most agents don’t. Some genuinely do, and forcing those into a linear chain doesn’t fail loudly β it fails as flaky behavior, silent state loss, and retries that quietly re-run side effects. This post is about knowing which category you’re in before you write a line of orchestration code.
You’ll learn:
- What a LangChain chain and
AgentExecutoractually do under the hood - The three concrete symptoms that mean you’ve outgrown a chain
- How LangGraph models control flow as a graph with nodes, edges, and shared state
- A side-by-side comparison of
AgentExecutorand aStateGraphbuilt for the same task - How LangGraph’s checkpointer gives you durable, resumable state that a chain doesn’t have
- A decision framework for choosing between the two on your next project
- What migrating an existing
AgentExecutoragent to LangGraph actually involves
Table of Contents
- What LangChain Actually Is
- Where a Chain Stops Being Enough
- What LangGraph Adds
- AgentExecutor vs StateGraph: Same Agent, Two Ways
- Durable State and Why It Matters
- When to Use LangGraph β and When Not To
- Migrating from AgentExecutor to LangGraph
- Where to Go From Here
What LangChain Actually Is
LangChain’s core abstraction is the chain: a sequence of steps β a prompt template, a model call, an output parser β composed with the | operator via LCEL (LangChain Expression Language). Each step takes the previous step’s output as input. It’s a pipeline, and pipelines are a genuinely good model for anything that’s actually linear: retrieve documents, stuff them into a prompt, call the model, parse the response.
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("Summarize this in two sentences:\n\n{text}")
model = ChatAnthropic(model="claude-sonnet-5")
chain = prompt | model | StrOutputParser()
result = chain.invoke({"text": long_document})
Agents built on top of this use AgentExecutor, which wraps the same idea in a loop: call the model with a set of tools bound to it, check whether the response asks for a tool call, run the tool if so, feed the result back in, and repeat until the model returns a final answer instead of a tool call.
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful research assistant."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(model, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=10)
result = executor.invoke({"input": "What's the current weather in Lahore, and is it good for a run?"})
This works well for a single-agent, single-thread loop with a bounded number of tool calls. The loop is opaque, though β you don’t control what happens between iterations, you can’t pause it mid-loop and resume it later, and there’s no way to have two branches of reasoning run and then merge back together. max_iterations is the only real safety valve, and it’s a blunt one: it just stops the loop, it doesn’t let you redirect it.
Where a Chain Stops Being Enough
Three symptoms reliably show up right before a team reaches for LangGraph. None of them are exotic β they’re what happens naturally once an agent’s job gets slightly more real.
1. The agent needs to loop back on itself with a purpose, not just retry. A chain retries by re-running the same fixed sequence. But a genuinely useful pattern β write code, run tests, read the failure, fix the code, run tests again β requires the outcome of one step to determine which step runs next, potentially cycling several times before exiting. AgentExecutor can technically get there through repeated tool calls, but you have no visibility or control over the cycle itself; it’s just “the loop happened to call the tool again.”
2. The agent needs to branch and possibly run steps in parallel. Say a request should either go to a fast-path answer or a multi-step research path, decided by classifying the query first. A chain is one path. Getting a branch in AgentExecutor means writing custom logic outside the executor and re-invoking it conditionally β at which point you’re building your own orchestration on top of an abstraction that wasn’t designed to expose it.
3. The agent’s state needs to survive past the process that’s running it. If a chain’s process crashes, restarts, or a request needs to pause for minutes (a human review) or days (waiting on an external event), AgentExecutor has nowhere to put that state. It lives in memory, in Python variables, for the duration of a single .invoke() call. When that call ends, so does everything it knew.
If none of these three apply to what you’re building, a chain is not a lesser choice β it’s usually the right choice, because it’s simpler to reason about, cheaper to run (no graph traversal overhead), and easier to debug with a linear stack trace.
What LangGraph Adds
LangGraph reframes an agent’s control flow as an explicit graph: a set of nodes (each one a function that receives the current state and returns updates to it), edges (which connect nodes, either unconditionally or based on a routing function), and a shared state object that every node reads from and writes to. Cycles are not a workaround here β they’re the default case a graph naturally supports, because an edge can point back to a node you’ve already visited.
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
def call_model(state: AgentState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def should_continue(state: AgentState):
last_message = state["messages"][-1]
return "tools" if last_message.tool_calls else END
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")
graph = builder.compile()
This looks more verbose than AgentExecutor for the same basic loop, and it is β that’s the tradeoff. What you get in exchange is that the cycle (agent β tools β agent) is now a fact you can see, modify, and reason about instead of something buried inside a library’s internal loop. You can add a third node that does nothing but log the state, insert a conditional edge that routes to a human-review node instead of looping back automatically, or run two independent branches from the same node and merge their results β none of which fit naturally into AgentExecutor’s model.
The comparison people actually mean when they say “LangGraph vs AgentExecutor” is this: AgentExecutor gives you a fixed loop shape with tool-calling built in; StateGraph gives you an arbitrary graph shape where tool-calling is just one common pattern you build on top of, not a shape the framework hands you for free.
AgentExecutor vs StateGraph: Same Agent, Two Ways
To make the difference concrete, here’s the same “research and summarize” agent, once as an AgentExecutor, once as a StateGraph with an explicit review branch.
AgentExecutor version β linear, no branch:
executor = AgentExecutor(agent=agent, tools=[search_tool, summarize_tool])
result = executor.invoke({"input": "Research recent LLM pricing changes and summarize."})
Every run follows the same implicit path: model decides, tool runs, model decides again. If you wanted “summarize only if the research turned up more than three sources, otherwise ask a follow-up,” you’d need to post-process the executor’s output and possibly re-invoke it β glue code sitting outside the framework.
StateGraph version β same task, with an explicit conditional branch:
def route_after_research(state: AgentState) -> str:
sources = state.get("source_count", 0)
return "summarize" if sources > 3 else "ask_followup"
builder = StateGraph(AgentState)
builder.add_node("research", research_node)
builder.add_node("summarize", summarize_node)
builder.add_node("ask_followup", followup_node)
builder.add_edge(START, "research")
builder.add_conditional_edges("research", route_after_research)
builder.add_edge("summarize", END)
builder.add_edge("ask_followup", END)
graph = builder.compile()
The branch is now a first-class part of the graph definition β visible, testable in isolation (you can unit-test route_after_research as a pure function), and independent of how many tool calls the research step needed internally. That’s the actual delta: not “LangGraph is smarter,” but “the control flow decision moved from implicit (inside the loop) to explicit (a node in the graph).”
Parallel Branches: Something AgentExecutor Can’t Do At All
The research/summarize example above is a conditional branch β one path or the other. LangGraph also supports genuine fan-out/fan-in: multiple nodes running from the same predecessor, with the graph waiting for all of them before continuing. This has no real equivalent in AgentExecutor, whose loop is inherently single-threaded through the model.
def route_to_all_sources(state: AgentState) -> list[str]:
return ["search_web", "search_docs", "search_internal_db"]
builder.add_conditional_edges("plan", route_to_all_sources)
builder.add_edge("search_web", "merge_results")
builder.add_edge("search_docs", "merge_results")
builder.add_edge("search_internal_db", "merge_results")
builder.add_node("merge_results", merge_node)
Each of the three search nodes runs independently and writes its findings into the shared state; merge_results only executes once all three have completed, and it sees the combined state. Trying to replicate this inside AgentExecutor means stepping outside the framework entirely β running your own asyncio.gather() over separate executor instances and stitching the results back together by hand, which is exactly the kind of orchestration code LangGraph is meant to absorb.
A Quick Side-by-Side
AgentExecutor | LangGraph StateGraph | |
|---|---|---|
| Control flow shape | Fixed loop (call model, run tool, repeat) | Arbitrary graph you define |
| Branching | Not native β requires external glue code | add_conditional_edges, first-class |
| Parallel fan-out | Not supported | Native, via multiple edges from one node |
| Cycles | Implicit inside the loop, opaque | Explicit edges, fully visible |
| Persisted state | None β lives in memory for one .invoke() | Checkpointer (memory, SQLite, Postgres) |
| Resuming after a restart | Not possible | thread_id + checkpointer |
| Debugging | Stack trace through library internals | Inspect state at any node boundary |
| Learning curve | Lower β a handful of function calls | Higher β you define nodes, edges, and state explicitly |
Durable State and Why It Matters
The other piece AgentExecutor genuinely lacks is a checkpointer. LangGraph can persist the full state object after every node executes, keyed by a thread_id, using a pluggable backend β in-memory for development, Postgres or SQLite for anything that needs to survive a process restart.
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "user-42-session-1"}}
graph.invoke({"messages": [("user", "Start the research task")]}, config)
Because every node’s output is checkpointed, you can call graph.invoke() again later with the same thread_id and it picks up exactly where it left off β same message history, same intermediate state, no re-running of side effects that already happened. AgentExecutor has no equivalent: kill the Python process mid-run and the agent’s entire working memory is gone. This durability is also what makes patterns like human-in-the-loop review practical instead of theoretical β a graph can pause at a node, wait indefinitely, and resume with the state exactly as it left it. That’s the subject of its own deep dive, linked below.
When to Use LangGraph β and When Not To
Use a plain LangChain chain (with or without AgentExecutor) when:
- The task is fundamentally linear: retrieve, augment, generate.
- Tool calls are bounded and don’t need conditional branching based on their results.
- The whole interaction completes within a single process lifetime β no pausing across restarts or waiting on a human for an extended period.
- You value the smaller, more predictable surface area of a simpler abstraction.
Reach for LangGraph when at least one of these is true:
- The agent needs a cycle whose exit condition depends on evaluated state, not just “the model stopped calling tools” (test-fix-retest loops, multi-round negotiation between sub-agents).
- You need explicit branching β different paths for different classifications of the same input, or parallel branches that fan out and later merge.
- The workflow must survive a process restart, or needs to pause for an unbounded amount of time (human approval, waiting on an async webhook) and resume later with full context intact.
- You’re building a multi-agent system where several agents need to share and update a common state object rather than just passing text back and forth.
A useful gut check: if you can draw your agent’s logic as a single arrow going left to right, you don’t need a graph yet. The moment you draw an arrow that loops back, or two arrows that come out of the same box, you’re already describing a graph β LangGraph just gives that drawing an execution engine.
Migrating from AgentExecutor to LangGraph
If you’re migrating an existing agent rather than starting fresh, the mechanical part is smaller than it looks:
- Model your state. Take whatever
AgentExecutorwas threading through invocations (usually justmessages) and define it as aTypedDict. - Turn your tool-calling loop into two nodes. One node calls the model with tools bound; a
ToolNode(LangGraph ships this prebuilt) executes any requested tool calls. - Replace
max_iterationswith an explicit conditional edge. Instead of a hard cap, write a routing function that decides whether to loop back to the model node or exit β you can still cap iteration count inside that function if you want the same safety net. - Add a checkpointer before you add anything else. Even
MemorySaverin development gets you used to threading athread_idthrough every call, which is the part most migrations forget until they need durability in production. - Only then add the branch or cycle you actually needed. The graph shape you migrate to should be no more complex than the
AgentExecutorloop you had β additional nodes are for the new behavior you’re adding, not required scaffolding.
Most of this is a few hours of work for a small agent, not a rewrite. The bigger shift is mental: you stop thinking of the agent as “a loop with tools” and start thinking of it as “a state machine with a model in some of the nodes.”
Where to Go From Here
This post covers the decision point β when a chain stops being enough and a graph starts paying for itself. The rest of this series picks up from there, going deeper into the pieces that only matter once you’ve made that jump:
- Human-in-the-Loop Agents with LangGraph β how
interrupt()and a Postgres-backed checkpointer let a graph pause for a human review and resume days later without losing state. - Building an MCP Server in Python β once your agent’s tool surface grows, this covers exposing tools, resources, and prompts through the Model Context Protocol instead of hand-wiring them into your graph.
- Cutting LLM Costs Without Shipping a Worse Agent β model routing, prompt caching, and context trimming for agents that call a model on every node.
- Structured Outputs from LLMs That Don’t Break in Production β getting reliable, schema-validated output out of the model calls sitting inside your graph’s nodes.
Wrapping Up
AgentExecutor and StateGraph aren’t a “which is better” comparison β they’re solving for different amounts of control flow complexity. A chain is the right tool when your agent’s logic really is a straight line with some tool calls along the way, and reaching for a graph before you need one just adds nodes and edges you have to maintain for no behavioral gain. But the moment your agent needs to cycle with purpose, branch on evaluated state, or survive longer than a single process lifetime, a chain doesn’t gracefully degrade β it starts accumulating workarounds bolted on from outside the framework. LangGraph exists for exactly that moment, and knowing which side of it you’re on before you start writing orchestration code will save you a rewrite later.
Related: See how an MCP server supports software investigation in Reverse Engineer Anything (REA): setup and use cases.

