If LangGraph has a heart, it is the contract between state, nodes, and edges. Everything advanced in the framework reduces to these three concepts behaving predictably.
State is the evolving record of the run. Nodes are named units of work that read from that record and return updates. Edges decide what executes next. Once those relationships feel concrete, the framework stops looking magical and starts looking like disciplined application design.
Teams that struggle with LangGraph usually do not have a graph problem first. They have a state-shape problem, a node-boundary problem, or an unclear routing problem. This page is where those habits get fixed.
A LangGraph state schema tells every node what data may exist at a given point in the run. It also tells human reviewers, debuggers, and tests what the graph is supposed to be carrying across steps.
Typed state pays for itself quickly. Whether you use `TypedDict`, dataclasses, or a stricter model, explicit fields reduce accidental overwrites and make route conditions readable.
A good node name sounds like a workflow decision or action: `classify_request`, `fetch_policy`, `draft_reply`, `approve_refund`. If the node name feels vague, the node probably does too much.
Nodes should be small enough to test with a tiny state fixture. When a node both calls three services and makes two business decisions, you lose the main advantage of graph orchestration: inspectable, composable steps.
An edge is the bridge between one step and the next. Fixed edges are for guaranteed order. Conditional edges are for branching based on state. START is the entry marker, and END is the explicit stop.
This is why graphs are easier to operate than prompt soups: you can point at a route and explain why execution went there.
Suppose a support graph starts with `message`, then classifies, retrieves context, and drafts a response. Each node adds or updates a small slice of state, and the final state contains the accumulated working memory of the run.
This makes debugging natural. You do not ask only whether the final answer was wrong. You ask where the state first became wrong.
`TypedDict` is the lightest option and great for tutorials. Dataclasses help when you want defaults and a more object-like shape. Pydantic or equivalent validation layers help when external inputs are messy and the cost of bad state is high.
The right choice depends on how much validation you need and how complex your team wants the state layer to be. The main lesson is not which schema tool wins; it is that state deserves deliberate design.
| Schema Style | Strength | Typical Use |
|---|---|---|
| TypedDict | Low ceremony and readable examples | Early tutorial code and small graphs |
| Dataclass | Defaults and structured objects | Internal application state with computed setup |
| Validated models | Stronger boundary checking | Production inputs and risky workflows |
The core LangGraph concepts are easiest to understand as contracts. State is the shared contract for what the workflow knows. Nodes are contracts for named units of work. Edges are contracts for what can happen next. When these contracts are clear, the graph is easy to test, debug, and explain.
State should be explicit and typed. Avoid hiding the entire workflow inside one messages list or a generic dictionary. Separate user input, working decisions, evidence, tool results, approval status, retry counts, and final output. Clear state fields make routes simpler and traces more useful.
Nodes should do one kind of work. A node that classifies the request should not also call tools, write final output, and decide retry behavior. Smaller nodes make it easier to isolate mistakes. If a node output is wrong, you can fix that node without questioning the whole graph.
Edges should make execution visible. A graph with explicit edges is easier to review than a long function full of hidden branches. Even when a workflow is simple, naming the steps helps future readers understand why the process exists and where new behavior should be added.
Take one workflow and write the state schema first. For every field, mark whether it is input, working state, accumulated evidence, operational metadata, or final output. This prevents state from becoming an unstructured dumping ground.
Next, name the nodes and edges without writing implementation code. If the graph is hard to explain at this level, the workflow is not understood yet. Clear node names and explicit edges make the later code feel almost obvious.
This graph shows how later nodes read values created by earlier nodes.
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class SupportState(TypedDict):
message: str
category: str
reply: str
def classify(state: SupportState) -> dict:
text = state["message"].lower()
return {"category": "billing" if "refund" in text else "general"}
def draft_reply(state: SupportState) -> dict:
return {"reply": f"Routing as {state['category']} support."}
builder = StateGraph(SupportState)
builder.add_node("classify", classify)
builder.add_node("draft_reply", draft_reply)
builder.add_edge(START, "classify")
builder.add_edge("classify", "draft_reply")
builder.add_edge("draft_reply", END)
Dataclasses can make internal workflow state easier to initialize when several fields have safe defaults.
from dataclasses import dataclass, field
from langgraph.graph import StateGraph, START, END
@dataclass
class ReviewState:
document_id: str
findings: list[str] = field(default_factory=list)
approved: bool = False
def inspect_document(state: ReviewState) -> dict:
return {"findings": ["missing signature", "totals look correct"]}
def route_decision(state: ReviewState) -> dict:
return {"approved": len(state.findings) == 0}
builder = StateGraph(ReviewState)
builder.add_node("inspect_document", inspect_document)
builder.add_node("route_decision", route_decision)
builder.add_edge(START, "inspect_document")
builder.add_edge("inspect_document", "route_decision")
builder.add_edge("route_decision", END)
Conditional routes let one completed node fan out to different next steps depending on current state.
from typing_extensions import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
class AuditState(TypedDict):
amount: float
risk: str
action: str
def score_risk(state: AuditState) -> dict:
if state["amount"] > 5000:
return {"risk": "high"}
if state["amount"] > 1000:
return {"risk": "medium"}
return {"risk": "low"}
def next_step(state: AuditState) -> Literal["manual_review", "spot_check", "auto_approve"]:
mapping = {
"high": "manual_review",
"medium": "spot_check",
"low": "auto_approve",
}
return mapping[state["risk"]]
Yes. Async nodes are common when calling network tools, model providers, or remote persistence layers.
No. Keep state limited to what later nodes, debugging, or persistence actually need.
Usually `TypedDict`, because it keeps the learning focus on graph concepts rather than framework ceremony.
Explore 500+ free tutorials across 20+ languages and frameworks.