teachyou.ai academy
← All posts
LangGraph

LangGraph Migration Guide: Moving from Plain LangChain Agents

Ira Menon · Jun 18, 2026 · 14 min read

Why Your LangChain Agent Keeps Breaking in Production

If you built an agent with initialize_agent or AgentExecutor sometime in the last couple of years, you've probably hit the same wall everyone else hits: it works beautifully in a notebook, then falls apart the moment you need it to do something slightly more sophisticated than "think, call a tool, think again, answer." You want the agent to pause for human approval before a risky action. You want it to retry a failed tool call with a different strategy. You want two agents to hand off work to each other. You want to persist state across a conversation that spans hours, not seconds.

Plain LangChain agents were never designed for this. AgentExecutor is a while-loop wearing an object-oriented costume — it repeats a "call the LLM, parse the output, maybe call a tool" cycle until the LLM says it's done. That loop has no concept of branching, no first-class way to pause and resume, and no clean mechanism for cycles that aren't just "try again." Every time you needed something outside that loop, you ended up hacking around it with custom callbacks, manual state dictionaries passed through closures, or giving up and writing your own orchestration from scratch.

LangGraph exists to fix exactly this. It models your agent as a graph — nodes are units of work, edges are transitions, and a shared state object flows through the whole thing. It's not a new framework bolted onto LangChain; it's the successor to AgentExecutor for anything beyond the simplest agent loop. This guide walks through what actually changes when you migrate, with real before/after code, so you can move a working LangChain agent to LangGraph without rewriting your entire application in one terrifying weekend.

The Core Mental Model Shift

Before touching code, it's worth being explicit about what's different, because most migration pain comes from people trying to force LangGraph into the old mental model instead of adopting the new one.

Plain LangChain agents are built around a hidden loop. You configure an AgentExecutor with an LLM, a set of tools, and a prompt. Internally, it repeatedly asks the LLM "what should I do next," parses the response into either a tool call or a final answer, executes the tool if needed, and feeds the result back in. You don't see the loop — you configure it and hope it behaves.

LangGraph makes the loop explicit and editable. You define:

  • A state schema — the data that flows through your agent (messages, intermediate results, flags, counters).
  • Nodes — plain Python functions that take the current state and return updates to it.
  • Edges — the paths between nodes, which can be fixed or conditional (a function that inspects state and decides where to go next).

Because the loop is now data (a graph you construct), you can add branches, loops-within-loops, human-in-the-loop interrupts, and parallel branches without fighting a black box. The tradeoff is that you write a bit more explicit code up front. In exchange, you get an orchestration layer that's actually debuggable — you can inspect state at every node, resume from a checkpoint, and reason about control flow the same way you'd reason about a state machine.

Setting Up: What You Need to Install

Before migrating, make sure your environment has both the legacy pieces and the new ones, since most real migrations run side by side for a while.

pip install langchain langchain-openai langgraph langgraph-checkpoint-sqlite

You don't need to uninstall langchain — LangGraph builds on top of the same LLM wrappers, tool definitions, and message types you already use. The migration is about replacing AgentExecutor with a graph, not about throwing away your tools, prompts, or model configuration.

Migration Step 1: Mapping Your Existing Agent

Start with a typical plain LangChain agent — a research assistant with two tools, built the way most tutorials teach it.

# BEFORE: plain LangChain AgentExecutor
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool

@tool
def search_docs(query: str) -> str:
    """Search internal documentation for relevant passages."""
    return f"Top result for '{query}': ... (mocked)"

@tool
def calculate(expression: str) -> str:
    """Evaluate a basic math expression."""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"

llm = ChatOpenAI(model="gpt-4o", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful research assistant. Use tools when needed."),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

tools = [search_docs, calculate]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

result = executor.invoke({"input": "What's 42 * 17, and find docs on rate limiting?"})
print(result["output"])

This works fine for a single-shot question-and-answer flow. The problem shows up the moment you need to know, mid-run, whether the agent decided to call a tool versus answer directly — or when you want to interrupt before calculate runs a potentially unsafe expression. AgentExecutor hides that decision inside its internal loop. You can attach callbacks to observe it, but you can't easily redirect it.

Here's the same agent rebuilt in LangGraph using the prebuilt ReAct-style constructor, which is the fastest migration path for tool-calling agents.

# AFTER: LangGraph, using the prebuilt agent constructor
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

@tool
def search_docs(query: str) -> str:
    """Search internal documentation for relevant passages."""
    return f"Top result for '{query}': ... (mocked)"

@tool
def calculate(expression: str) -> str:
    """Evaluate a basic math expression."""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"

llm = ChatOpenAI(model="gpt-4o", temperature=0)
tools = [search_docs, calculate]

graph = create_react_agent(
    llm,
    tools,
    prompt="You are a helpful research assistant. Use tools when needed.",
)

result = graph.invoke({
    "messages": [("human", "What's 42 * 17, and find docs on rate limiting?")]
})
print(result["messages"][-1].content)

Functionally, this is nearly a drop-in replacement — same tools, same LLM, same prompt intent. But now you're holding a compiled graph object instead of an opaque executor, and that graph can be introspected, streamed node-by-node, checkpointed, and extended with new nodes. This is the right first move for most migrations: swap AgentExecutor for create_react_agent and confirm behavior is equivalent before you touch anything else.

Migration Step 2: Owning Your State Explicitly

The prebuilt agent is a good stepping stone, but the real value of LangGraph shows up when you define your own graph with an explicit state schema, because that's what lets you add fields beyond just messages — things like retry counters, user permissions, or intermediate scratch data.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage

class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    tool_call_count: int
    needs_approval: bool

add_messages is a reducer — instead of overwriting the messages list every time a node returns, LangGraph appends new messages to it. This is the same pattern that made AgentExecutor's scratchpad work, except now it's a schema field you control rather than internal machinery.

Next, define the nodes as plain functions:

from langchain_core.messages import AIMessage

llm_with_tools = llm.bind_tools(tools)

def call_model(state: AgentState) -> dict:
    response = llm_with_tools.invoke(state["messages"])
    return {
        "messages": [response],
        "tool_call_count": state.get("tool_call_count", 0) + 1,
    }

def should_continue(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        if state.get("tool_call_count", 0) > 5:
            return "end"  # safety valve against infinite tool loops
        return "tools"
    return "end"

Notice the tool_call_count > 5 check inside should_continue. In plain LangChain, guarding against runaway tool loops meant setting max_iterations on AgentExecutor and hoping the generic cutoff didn't trigger too early or too late. In LangGraph, the cutoff logic lives in your own conditional edge function, so you can make it as specific as your application needs — different limits per tool, different behavior on cutoff (route to a fallback node instead of just stopping), or a check based on elapsed time instead of a raw counter.

Now wire the graph together:

from langgraph.prebuilt import ToolNode

tool_node = ToolNode(tools)

builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", tool_node)

builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {
    "tools": "tools",
    "end": END,
})
builder.add_edge("tools", "agent")

graph = builder.compile()

result = graph.invoke({
    "messages": [("human", "What's 42 * 17, and find docs on rate limiting?")],
    "tool_call_count": 0,
    "needs_approval": False,
})
print(result["messages"][-1].content)

This is structurally the same loop AgentExecutor ran internally, but every part of it is now a function you own. If you need to log something between the model call and the tool call, add a node. If you need to skip tool execution under certain conditions, change the conditional edge. There's no framework internals to work around.

Migration Step 3: Adding Human-in-the-Loop Approval

This is usually the feature that actually forces teams off AgentExecutor. Plain LangChain has no clean way to pause mid-execution, show a human what the agent wants to do, and resume only after approval. People simulate it with custom tool wrappers that raise exceptions, but it's fragile and doesn't survive process restarts.

LangGraph handles this natively with interrupts and a checkpointer. First, add persistence:

from langgraph.checkpoint.sqlite import SqliteSaver

checkpointer = SqliteSaver.from_conn_string("agent_state.db")
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["tools"])

interrupt_before=["tools"] tells LangGraph to pause every time it's about to enter the tools node — right after the model decides to call a tool, but before that tool actually executes. Each run needs a thread ID so LangGraph knows which conversation's state to checkpoint:

config = {"configurable": {"thread_id": "conversation-42"}}

result = graph.invoke({
    "messages": [("human", "Calculate 999999 * 999999 and search docs on it")],
    "tool_call_count": 0,
    "needs_approval": False,
}, config=config)

# Execution paused before the tool node. Inspect what it wants to do:
pending_state = graph.get_state(config)
last_ai_message = pending_state.values["messages"][-1]
print("Agent wants to call:", last_ai_message.tool_calls)

# Show this to a human. If they approve, resume with None as input:
final_result = graph.invoke(None, config=config)
print(final_result["messages"][-1].content)

If the human rejects the action instead, you can update the state directly before resuming — for example, injecting a message that tells the model the action was denied and it should try something else:

graph.update_state(config, {
    "messages": [("human", "That action was denied. Please try a different approach.")]
})
final_result = graph.invoke(None, config=config)

Because the checkpointer persists state to SQLite (or Postgres, or Redis, depending on which checkpoint backend you pick), this pause can last seconds or days. The process can restart entirely, and as long as the thread ID and the database are still there, graph.invoke(None, config=config) picks up exactly where it left off. This is simply not possible with AgentExecutor — there's no serialized state to resume from, because the loop's state lives in local Python variables inside a single function call.

Migration Step 4: Replacing Memory and Conversation History

Plain LangChain agents typically use ConversationBufferMemory or similar memory classes attached to the executor:

# BEFORE
from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
executor = AgentExecutor(agent=agent, tools=tools, memory=memory, verbose=True)

These memory classes are being deprecated in favor of the checkpointer pattern shown above, and for good reason — memory classes only track message history, while a checkpointer tracks your entire state schema (messages, counters, flags, anything you add). In LangGraph, memory isn't a bolted-on object; it's just what the checkpointer persists between invocations of the same thread ID.

# AFTER
config = {"configurable": {"thread_id": "user-123-session-1"}}

graph.invoke({"messages": [("human", "My name is Priya.")], "tool_call_count": 0, "needs_approval": False}, config=config)
result = graph.invoke({"messages": [("human", "What's my name?")], "tool_call_count": 0, "needs_approval": False}, config=config)
print(result["messages"][-1].content)  # "Your name is Priya."

Each call only needs the new human message — the checkpointer automatically loads prior state for that thread_id and merges it via the add_messages reducer. If you were previously running multiple memory types (buffer memory for chat, a separate vector store for long-term facts), you can now represent both inside a single state schema and manage retrieval logic explicitly in a node, rather than juggling two disconnected memory objects.

Migration Step 5: Handling Multi-Agent Handoffs

If your plain LangChain setup used a "router" pattern — one LLM call deciding which of several specialized chains to invoke — that pattern maps directly onto conditional edges between subgraphs.

# BEFORE: manual router with if/else and separate chains
def route_query(user_input: str):
    classification = router_chain.invoke({"input": user_input})
    if classification == "billing":
        return billing_chain.invoke({"input": user_input})
    elif classification == "technical":
        return tech_chain.invoke({"input": user_input})
    else:
        return general_chain.invoke({"input": user_input})
# AFTER: LangGraph with a routing node and specialized subgraphs
class RouterState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    route: str

def classify(state: RouterState) -> dict:
    last_message = state["messages"][-1].content
    classification = router_llm.invoke(
        f"Classify this as billing, technical, or general: {last_message}"
    ).content.strip().lower()
    return {"route": classification}

def route_decision(state: RouterState) -> str:
    return state.get("route", "general")

router_builder = StateGraph(RouterState)
router_builder.add_node("classify", classify)
router_builder.add_node("billing", billing_node)
router_builder.add_node("technical", technical_node)
router_builder.add_node("general", general_node)

router_builder.add_edge(START, "classify")
router_builder.add_conditional_edges("classify", route_decision, {
    "billing": "billing",
    "technical": "technical",
    "general": "general",
})
router_builder.add_edge("billing", END)
router_builder.add_edge("technical", END)
router_builder.add_edge("general", END)

router_graph = router_builder.compile()

The advantage over the plain if/else version isn't just style — each of billing_node, technical_node, and general_node can itself be a full LangGraph subgraph with its own tools, its own loop, and its own checkpointing, and you can compose them because LangGraph subgraphs are themselves invokable nodes. Doing this with separate LangChain chains means writing your own glue code for passing state between them and no shared way to checkpoint the combination.

Testing Your Migration Without Breaking Production

The biggest practical risk in a migration like this isn't writing the graph — it's shipping it without knowing whether it behaves the same as the old executor for your existing test cases. A few things that matter in practice:

  • Keep your existing tool functions unchanged. Both AgentExecutor and create_react_agent consume the same @tool-decorated functions, so tool logic doesn't need to move.
  • Run the same evaluation prompts through both systems and diff the outputs before cutting over traffic. If you already have a test suite of example queries and expected tool calls, that suite is directly reusable.
  • Migrate one agent at a time if you run several. Multi-agent systems are much easier to reason about once even one component is a proper graph, so you don't need a big-bang rewrite.
  • Watch iteration limits carefully. AgentExecutor's max_iterations and LangGraph's manual counter-based cutoff are not automatically equivalent — test the edge case where your agent used to hit the limit and verify the new graph handles it the way you expect, whether that's stopping, falling back, or asking for help.
  • Use graph.stream() instead of graph.invoke() during testing so you can watch state transition node by node, which makes debugging discrepancies against the old executor's verbose=True output far easier.
for step in graph.stream({
    "messages": [("human", "What's 42 * 17?")],
    "tool_call_count": 0,
    "needs_approval": False,
}, config=config, stream_mode="values"):
    print(step["messages"][-1])

Common Migration Pitfalls

A few mistakes come up repeatedly when teams make this move.

  • Forgetting the reducer on the messages field. If you define messages: list[BaseMessage] without Annotated[..., add_messages], each node's return value overwrites the whole list instead of appending, and your agent loses history on every step.
  • Not passing a thread ID. Without config={"configurable": {"thread_id": ...}}, the checkpointer has nothing to key state on, and every invocation starts fresh even though you attached a checkpointer.
  • Assuming `interrupt_before` pauses after the tool runs. It pauses before the named node executes — so interrupt_before=["tools"] gives you a chance to veto the call, not to review its result. If you want to review results, use interrupt_after instead.
  • Treating the prebuilt `create_react_agent` as the finish line. It's a great first migration step, but it's still a fairly rigid loop. The real payoff — human approval, branching, subgraphs, custom cutoffs — comes from building your own StateGraph, even if you start by copying the prebuilt agent's internal structure as a template.
  • Running eval only on the happy path. The differences between AgentExecutor and a hand-built graph show up most in edge cases — tool errors, iteration limits, ambiguous routing — so that's where your before/after comparisons should concentrate.

Wrapping Up

Migrating from a plain LangChain agent to LangGraph isn't a rewrite so much as an unwrapping — you're taking the implicit loop AgentExecutor ran on your behalf and turning it into an explicit graph you control. Your tools stay the same. Your LLM configuration stays the same. What changes is that state, control flow, memory, and human-in-the-loop checkpoints all become code you can read, test, and extend, instead of framework behavior you have to work around.

The practical path is incremental: swap AgentExecutor for create_react_agent first and confirm parity, then move to a hand-built StateGraph once you need custom cutoffs, approval steps, or multi-agent routing. Do it agent by agent, test edge cases as hard as happy paths, and you'll end up with something far more maintainable than what you started with.

If you want to go deeper on graphs, checkpointers, subgraphs, and multi-agent patterns with guided projects instead of piecing it together from docs, check out the LangGraph Tutorial course on teachyou.ai — it walks through exactly this kind of migration end to end.