teachyou.ai academy
← All posts
LangGraph

LangGraph vs Plain Python State Machines: Why Use a Framework

Pramod Dutta · Jun 15, 2026 · 14 min read

The Argument Every AI Engineering Team Has

At some point in every serious agentic project, someone on the team asks the question out loud: "Why don't we just write this as a while loop with a dictionary for state?" It's a fair question. You've got a handful of steps — call a model, check a condition, maybe call a tool, loop back or exit — and it feels like reaching for LangGraph is like using a shipping container to move a houseplant. Plain Python is faster to write on day one, has zero new concepts to learn, and doesn't require you to trust someone else's abstraction over control flow you could write yourself in twenty lines.

That instinct isn't wrong. It's just incomplete. The honest answer is that plain Python state machines and LangGraph solve the same underlying problem — controlling how an LLM-driven process moves between steps — but they diverge hard on what happens after the first working version ships. This article is not a LangGraph sales pitch. It's a side-by-side look at what you get, what you give up, and where the line actually sits, with real code for both approaches so you can judge for yourself.

What a Plain Python State Machine Actually Looks Like

Let's start with the thing everyone already knows how to build. A minimal agent loop in plain Python is usually a while loop, a state enum, and a dispatch dictionary. Something like this:

from enum import Enum, auto
from dataclasses import dataclass, field

class State(Enum):
    PLANNING = auto()
    TOOL_CALL = auto()
    REVIEWING = auto()
    DONE = auto()

@dataclass
class AgentState:
    messages: list = field(default_factory=list)
    tool_result: str | None = None
    retries: int = 0
    state: State = State.PLANNING

def run_planning(s: AgentState) -> AgentState:
    response = call_llm(s.messages)
    s.messages.append({"role": "assistant", "content": response.content})
    if response.wants_tool:
        s.state = State.TOOL_CALL
    else:
        s.state = State.DONE
    return s

def run_tool_call(s: AgentState) -> AgentState:
    s.tool_result = execute_tool(s.messages[-1])
    s.messages.append({"role": "tool", "content": s.tool_result})
    s.state = State.REVIEWING
    return s

def run_reviewing(s: AgentState) -> AgentState:
    response = call_llm(s.messages)
    if "needs more work" in response.content and s.retries < 3:
        s.retries += 1
        s.state = State.PLANNING
    else:
        s.state = State.DONE
    return s

def run(s: AgentState) -> AgentState:
    handlers = {
        State.PLANNING: run_planning,
        State.TOOL_CALL: run_tool_call,
        State.REVIEWING: run_reviewing,
    }
    while s.state != State.DONE:
        s = handlers[s.state](s)
    return s

This is a completely reasonable piece of code. It's readable, it's debuggable with a plain print statement, and any Python developer can maintain it without reading a single page of documentation. For a two- or three-state agent that one person owns end to end, this is often the *correct* choice, not just an acceptable one. Don't let anyone tell you otherwise.

The trouble starts when this graph grows. Real agent workflows tend to accrete states: error-handling branches, human-approval gates, parallel tool calls, retries with different strategies, sub-workflows that need their own state. Each new requirement means touching the central while loop and the dispatch table, and the state transitions — the actual shape of the graph — live implicitly in if statements scattered across handler functions instead of being visible anywhere as a single artifact.

What LangGraph Adds on Top of That Loop

LangGraph is, underneath everything, the same idea: a state object, a set of nodes, and edges that decide what runs next. The difference is that LangGraph makes the graph a first-class object instead of an emergent property of your code.

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END

class AgentState(TypedDict):
    messages: list
    tool_result: str | None
    retries: int

def planning(state: AgentState) -> AgentState:
    response = call_llm(state["messages"])
    return {"messages": state["messages"] + [response], "tool_result": None}

def tool_call(state: AgentState) -> AgentState:
    result = execute_tool(state["messages"][-1])
    return {"tool_result": result, "messages": state["messages"] + [result]}

def reviewing(state: AgentState) -> AgentState:
    response = call_llm(state["messages"])
    return {"messages": state["messages"] + [response]}

def route_after_planning(state: AgentState) -> Literal["tool_call", "end"]:
    return "tool_call" if wants_tool(state["messages"][-1]) else "end"

def route_after_review(state: AgentState) -> Literal["planning", "end"]:
    if needs_more_work(state["messages"][-1]) and state["retries"] < 3:
        return "planning"
    return "end"

graph = StateGraph(AgentState)
graph.add_node("planning", planning)
graph.add_node("tool_call", tool_call)
graph.add_node("reviewing", reviewing)

graph.set_entry_point("planning")
graph.add_conditional_edges("planning", route_after_planning, {"tool_call": "tool_call", "end": END})
graph.add_edge("tool_call", "reviewing")
graph.add_conditional_edges("reviewing", route_after_review, {"planning": "planning", "end": END})

app = graph.compile()
result = app.invoke({"messages": [], "tool_result": None, "retries": 0})

Look at what changed. Nothing about the actual logic is smarter — route_after_planning and route_after_review are the same conditionals you'd write in plain Python. What changed is that the *shape* of the workflow is now declared separately from the *behavior* of each step. You can look at the graph.add_edge and graph.add_conditional_edges calls and reconstruct the entire flow diagram without reading a single handler function body. That separation is the whole value proposition, and everything else LangGraph offers is built on top of it.

Where LangGraph Genuinely Earns Its Keep

Checkpointing and persistence. This is the single biggest practical reason teams adopt LangGraph over rolling their own. Once you compile a graph with a checkpointer, every state transition is automatically saved:

from langgraph.checkpoint.sqlite import SqliteSaver

checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "user-4471"}}
result = app.invoke({"messages": [...], "tool_result": None, "retries": 0}, config=config)

# Later, maybe after a server restart or a long human-approval wait:
result = app.invoke(None, config=config)  # resumes exactly where it left off

Try building this yourself in plain Python and you'll quickly discover it's not "add a database call." You need to serialize the full state at every transition, handle partial writes if the process crashes mid-step, and reconstruct not just the data but *which node runs next*. It's solvable, but it's a genuine subsystem, not a side feature — and it's the kind of code that's boring to write and easy to get subtly wrong (crash between the state write and the "what's next" write, and you've silently lost or duplicated a step).

Human-in-the-loop interrupts. Related to checkpointing: LangGraph lets you pause a graph before or after a specific node and wait indefinitely for external input, then resume from that exact point.

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

This means "pause and wait for a human to approve this tool call, possibly for days, then continue" is a compile-time flag instead of a bespoke queuing system you design, test, and maintain.

Visibility into the graph shape. Because the graph is declared as data — nodes and edges — you can inspect it, visualize it, and validate it before running anything.

print(app.get_graph().draw_mermaid())

That's a genuinely useful artifact for code review and onboarding. A new engineer can see the whole workflow in one diagram instead of tracing through conditionals in four different functions.

Streaming and observability hooks. LangGraph exposes per-node streaming (app.stream(...)) and integrates with LangSmith for tracing, so you get token-level and step-level visibility without instrumenting each handler yourself. You can build equivalent logging in plain Python, but again, it's work you're signing up to own.

Parallel branches and fan-out/fan-in. When a workflow genuinely needs to run several nodes concurrently and merge results, LangGraph's graph model handles this natively through its state-reducer pattern. In plain Python you'd reach for asyncio.gather and hand-roll the merge logic, which is fine for one case but gets repetitive across many.

Where Plain Python Still Wins

It would be dishonest to stop there, because LangGraph is not free, and the cost is real.

Cognitive overhead for simple cases. If your workflow is three steps with one conditional branch, you do not need TypedDict state schemas, reducers, conditional edge functions, and a compile step. You need a while loop. Introducing LangGraph here adds indirection: to understand what happens, a reader now has to hold the graph definition and the node functions in their head simultaneously, whereas the plain Python version reads top to bottom.

Debugging is a different experience. In plain Python, a stack trace points directly at the line that failed inside the function that failed. In LangGraph, exceptions bubble up through the graph executor, and depending on version and configuration, you sometimes have to dig through an extra layer of framework code to find your own bug. It's not opaque, but it's not pdb-simple either.

Dependency and version churn. LangGraph is an actively developed library. APIs have shifted between versions (state schema conventions, the checkpointer interface, and the messages/reducer patterns have all evolved). A plain Python state machine has exactly one dependency: Python itself. That stability matters if you're building something meant to run untouched for years.

You can absolutely hand-roll checkpointing if you only need a little of it. If "resume after a crash" just means "write the state dict to Postgres after each step and read it back on start," that's maybe 30 lines of code, not a framework's worth of abstraction. LangGraph's checkpointing shines when you need the *general* case — arbitrary resume points, time-travel debugging, multiple checkpointer backends — not when you need one specific narrow behavior.

Testing shape. Unit testing a plain Python handler function is testing a function: pass in a dataclass, assert on the output. Testing a LangGraph node in isolation is nearly identical, but testing the *routing logic* means either invoking the compiled graph or extracting the conditional function and testing it separately — an extra layer you have to remember exists.

A Concrete Migration Example

Here's a good way to reason about it rather than argue in the abstract: take the plain Python version above and ask what changes if you need to add one new requirement — a human approval step before any tool call that touches a payment API.

In plain Python:

class State(Enum):
    PLANNING = auto()
    AWAITING_APPROVAL = auto()
    TOOL_CALL = auto()
    REVIEWING = auto()
    DONE = auto()

def run_planning(s: AgentState) -> AgentState:
    response = call_llm(s.messages)
    s.messages.append({"role": "assistant", "content": response.content})
    if response.wants_tool and is_sensitive(response):
        s.state = State.AWAITING_APPROVAL
    elif response.wants_tool:
        s.state = State.TOOL_CALL
    else:
        s.state = State.DONE
    return s

def run_awaiting_approval(s: AgentState) -> AgentState:
    # Now you need to persist `s`, stop the loop, expose an endpoint
    # for the approval, and reconstruct `s` from storage when it comes in.
    save_state_to_db(s)
    raise PendingApproval(state_id=s.id)

That raise PendingApproval is where the real work begins — you now need a webhook or polling endpoint, a serialization format for AgentState, and a way to resume the while loop from AWAITING_APPROVAL instead of restarting from PLANNING. It's all doable, but it's new infrastructure.

In LangGraph:

def route_after_planning(state: AgentState) -> Literal["tool_call", "end"]:
    return "tool_call" if wants_tool(state["messages"][-1]) else "end"

graph.add_conditional_edges("planning", route_after_planning, {"tool_call": "tool_call", "end": END})
app = graph.compile(checkpointer=checkpointer, interrupt_before=["tool_call"])

You added one compile-time argument. The pause, the persistence, and the resume are handled by the framework because you already paid the setup cost of checkpointing earlier. This is the pattern that repeats across most LangGraph advantages: the first feature you need it for costs you nothing extra because it's built in; the equivalent feature in plain Python costs you real engineering time the first time you need it, every time.

A Framework for Deciding, Not a Verdict

Rather than a blanket recommendation, use these questions honestly:

  • How many states, and how many will there realistically be in a year? Under five states with one or two conditional branches: plain Python is usually the pragmatic choice. Growing past eight or ten states, or expecting to add branches regularly: the explicit graph structure starts paying for itself.
  • Do you need to survive a crash or a long wait mid-workflow? If any step might take hours or days (human review, external approval, batch processing), checkpointing is not a nice-to-have, and hand-rolling it well is a meaningful project on its own.
  • Will more than one engineer touch this workflow? A declared graph is something a second engineer can read without archaeology. Scattered if statements across handler functions are not, especially six months later when nobody remembers why a particular branch exists.
  • Do you need to swap the LLM backend or add new model providers frequently? LangGraph's ecosystem (LangChain integrations, standardized message formats) reduces friction here, though it's not the deciding factor by itself — plain Python with a thin provider wrapper handles this fine too.
  • Is this a one-off script or a system that will run in production for years? One-off: don't add a dependency you don't need. Long-lived production system with a team behind it: the framework's stability guarantees (once you pin a version) and tooling start to matter more than the initial learning curve.
  • Do you actually need parallel fan-out/fan-in, sub-graphs, or multi-agent orchestration? These are the cases where hand-rolling starts to genuinely hurt, because you're re-implementing graph execution semantics one edge case at a time.

Notice what's not on this list: "LangGraph is more powerful" or "plain Python is more pythonic." Both are true and both are irrelevant. The real question is always about maintenance cost over time versus setup cost today, and that answer depends entirely on your workflow's actual complexity — not its expected complexity, its actual, currently-known complexity.

The Middle Ground Nobody Talks About

There's a version of this decision that doesn't get discussed enough: you don't have to choose LangGraph or a while loop as a permanent architectural commitment. Plenty of teams start with the plain Python version because it's faster to prototype and validate the actual agent behavior — the prompts, the tool definitions, the retry logic — without the overhead of learning a graph API at the same time. Once the workflow proves itself and starts accumulating the symptoms described above (state count creeping up, a request for "can this survive a restart," a second engineer joining who needs to understand the flow), migrating the *node logic* into LangGraph nodes is mostly a mechanical refactor, because the actual business logic inside each handler function doesn't need to change — only how it's wired together does.

This is worth saying plainly because a lot of the "should we use a framework" debate gets treated as a one-time, irreversible fork in the road. It isn't. Writing your first version in plain Python is not wasted work if you later adopt LangGraph — the handler functions you wrote are close to directly reusable as nodes. What plain Python doesn't give you for free is the migration path in the other direction: ripping LangGraph out of a system that never needed it is more disruptive than adding LangGraph to a system that has outgrown a while loop.

Common Mistakes Teams Make With Both Approaches

A few patterns show up regardless of which side you pick, and they're worth naming so you don't repeat them.

  • Reaching for LangGraph before there's a graph. If you have one linear sequence of steps with no branching and no need for persistence, you don't have a state machine problem, you have a pipeline, and a plain function composition or a simple for loop over steps is clearer than either approach discussed here.
  • Building a custom checkpointing system "just in case." Don't add persistence infrastructure to a plain Python state machine speculatively. Add it when a real requirement (a long-running human step, a need to survive deploys) shows up, and at that point seriously reconsider whether LangGraph's built-in checkpointer is less total work than your own.
  • Treating LangGraph's conditional edges as a place to hide business logic. The routing functions should route. If route_after_planning starts calling external APIs and mutating state, you've just moved your if statement soup into a differently-shaped file. Keep routing functions pure and simple.
  • Not testing routing logic independently. In either approach, the conditional that decides "what happens next" is the highest-value thing to unit test, because it's where subtle bugs (off-by-one retries, wrong branch on an edge case) actually live. Test it directly, not just through an end-to-end run of the whole graph or loop.

Closing Thoughts

Neither approach is a trap, and neither is obviously correct in general. A plain Python state machine is honest, transparent, and dependency-free, and for a huge share of real agent workflows, it's genuinely the right call — resist the urge to add a framework because it's the trendy thing to reach for. LangGraph earns its complexity budget specifically when you need durable checkpointing, human-in-the-loop pauses, multi-agent coordination, or a workflow complex enough that the graph shape needs to be a visible, reviewable artifact rather than something implied by scattered conditionals. The mistake isn't picking either tool — it's picking one without being honest about which problems you actually have versus which ones you're pattern-matching from a blog post.

If you want to go deeper on building production-grade agent workflows with LangGraph — checkpointing, human-in-the-loop patterns, multi-agent graphs, and the migration path from simpler Python code — that's exactly what we cover step by step in the LangGraph Tutorial course on teachyou.ai, with working code for every pattern discussed here.