Recall Module 2’s mental map — five layers, and State sits at the very top of it for a genuine reason: everything else in this course is built on top of it. Get state design right, and nodes, routing, and everything that follows feels natural. Get it wrong, and you’ll spend real, frustrating time debugging problems that were actually schema problems in disguise. This module earns its full depth because of exactly that.
What a real workflow’s state actually needs to hold
Recall the customer resolution workflow from Module 1. Think concretely about what it genuinely needs to remember as it runs:
user_query — what the customer actually asked
customer_id — who this is
documents — anything retrieved to help answer
current_step — where in the workflow we are
tool_result — what the last action returned
draft_answer — the response being built
review_status — has it been checked yet
retry_count — how many times we've tried
This list — not an abstract concept, but this exact, concrete list — is what “state” means in LangGraph. It’s the genuine, inspectable memory of one specific workflow run.
Example 1: the simplest possible state
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
message: str
def uppercase(state: State) -> dict:
return {"message": state["message"].upper()}
builder = StateGraph(State)
builder.add_node("uppercase", uppercase)
builder.add_edge(START, "uppercase")
builder.add_edge("uppercase", END)
graph = builder.compile()
print(graph.invoke({"message": "hello"}))
Let’s genuinely watch what happened here, before and after, exactly as this module is going to keep doing for every example:
Before uppercase: After uppercase:
{ {
"message": "hello" "message": "HELLO"
} }
One field, one node, one clean transformation. This is the entire idea, at its smallest.
Example 2: multiple fields
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
user_query: str
customer_id: str
current_step: str
def start_workflow(state: State) -> dict:
return {"current_step": "classifying"}
builder = StateGraph(State)
builder.add_node("start_workflow", start_workflow)
builder.add_edge(START, "start_workflow")
builder.add_edge("start_workflow", END)
graph = builder.compile()
result = graph.invoke({"user_query": "Where's my refund?", "customer_id": "c_1", "current_step": ""})
print(result)
Before start_workflow: After start_workflow:
{ {
"user_query": "Where's my refund?", "user_query": "Where's my refund?",
"customer_id": "c_1", "customer_id": "c_1",
"current_step": "" "current_step": "classifying"
} }
Notice user_query and customer_id came through completely unchanged. This is worth naming precisely, because it’s the single most important behavior in this entire module.
Example 3: what a node’s return value actually does — merge, not replace
This deserves its own dedicated example, because it’s genuinely easy to misunderstand, and getting it wrong causes real, confusing bugs later.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
user_query: str
customer_id: str
current_step: str
def classify(state: State) -> dict:
# notice: we only return the ONE field this node actually changed
return {"current_step": "classified"}
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_edge(START, "classify")
builder.add_edge("classify", END)
graph = builder.compile()
result = graph.invoke({"user_query": "Where's my refund?", "customer_id": "c_1", "current_step": "new"})
print(result)
classify never mentions user_query or customer_id at all in its return value — and yet both survive, completely intact, in the final result. This is genuinely important to understand precisely: a node’s return value is merged into the existing state, field by field. Whatever a node doesn’t mention in its return, LangGraph leaves exactly as it was. A node never needs to manually copy forward every field it isn’t touching — only return what actually changed.
Example 4: optional fields
Real workflows have fields that genuinely might not exist yet at a given point.
from typing import TypedDict
from typing_extensions import NotRequired
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
user_query: str
documents: NotRequired[list[str]]
def maybe_retrieve(state: State) -> dict:
if "refund" in state["user_query"].lower():
return {"documents": ["refund-policy.txt"]}
return {}
builder = StateGraph(State)
builder.add_node("maybe_retrieve", maybe_retrieve)
builder.add_edge(START, "maybe_retrieve")
builder.add_edge("maybe_retrieve", END)
graph = builder.compile()
print(graph.invoke({"user_query": "Where's my refund?"}))
print(graph.invoke({"user_query": "What's your hours?"}))
NotRequired[list[str]] tells the type system this field is genuinely allowed to be absent. Notice the second call returns {} from the node — an empty update is completely valid, and documents simply never appears in that result at all, rather than showing up as None or an empty list you didn’t ask for.
Example 5: nested, structured data
Real state fields are rarely just strings — recall the documents and tool_result fields from this module’s opening list.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class ToolResult(TypedDict):
tool_name: str
output: str
success: bool
class State(TypedDict):
user_query: str
tool_result: ToolResult
def run_tool(state: State) -> dict:
return {"tool_result": {"tool_name": "retry_payment", "output": "Payment succeeded", "success": True}}
builder = StateGraph(State)
builder.add_node("run_tool", run_tool)
builder.add_edge(START, "run_tool")
builder.add_edge("run_tool", END)
graph = builder.compile()
result = graph.invoke({"user_query": "Retry my payment", "tool_result": {}})
print(result["tool_result"]["success"])
ToolResult being its own, separate TypedDict — nested inside State — keeps a genuinely structured piece of data organized and typed, rather than flattening everything into loose, top-level fields with no clear grouping.
Example 6: counters
Recall retry_count from this module’s opening list — a genuinely common, important pattern you’ll use directly once loops are covered properly.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
retry_count: int
def attempt(state: State) -> dict:
return {"retry_count": state["retry_count"] + 1}
builder = StateGraph(State)
builder.add_node("attempt", attempt)
builder.add_edge(START, "attempt")
builder.add_edge("attempt", END)
graph = builder.compile()
print(graph.invoke({"retry_count": 0}))
Notice this node genuinely reads the current value before writing a new one — state["retry_count"] + 1, not just 1. A counter has to be read and incremented deliberately; nothing about LangGraph makes counting happen automatically.
Example 7: status flags with real, constrained values
Recall review_status from the opening list — exactly the kind of field that should never accept an arbitrary string.
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
draft_answer: str
review_status: Literal["pending", "approved", "rejected"]
def review(state: State) -> dict:
return {"review_status": "approved"}
builder = StateGraph(State)
builder.add_node("review", review)
builder.add_edge(START, "review")
builder.add_edge("review", END)
graph = builder.compile()
print(graph.invoke({"draft_answer": "Your refund has been processed.", "review_status": "pending"}))
Literal["pending", "approved", "rejected"] constrains this field to exactly three real, valid values — the same real technique from your LangChain course’s structured output work, now protecting a workflow’s own internal state rather than a model’s final answer.
Example 8: Pydantic as an alternative to TypedDict
TypedDict is genuinely the most common choice, but current LangGraph also supports Pydantic models directly, when you want real, runtime validation rather than only static type checking.
from pydantic import BaseModel
from langgraph.graph import StateGraph, START, END
class State(BaseModel):
user_query: str
retry_count: int = 0
def attempt(state: State) -> dict:
return {"retry_count": state.retry_count + 1}
builder = StateGraph(State)
builder.add_node("attempt", attempt)
builder.add_edge(START, "attempt")
builder.add_edge("attempt", END)
graph = builder.compile()
print(graph.invoke({"user_query": "Where's my order?"}))
Notice the real, practical difference: state.retry_count, not state["retry_count"] — attribute access, not dictionary access, because State is now a genuine Pydantic class, not a TypedDict. Pydantic also gives you real, runtime validation — an invalid value genuinely raises an error the moment it’s constructed, not just a warning from your editor’s type checker.
Example 9: separating input, output, and internal state
Real applications often don’t want every internal field exposed to whoever calls the graph. Current LangGraph lets you define these separately.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class InputState(TypedDict):
user_query: str
class OutputState(TypedDict):
draft_answer: str
class OverallState(TypedDict):
user_query: str
internal_notes: str
draft_answer: str
def analyze(state: OverallState) -> dict:
return {"internal_notes": "classified as billing issue"}
def respond(state: OverallState) -> dict:
return {"draft_answer": f"Regarding your query: {state['user_query']} — here's your answer."}
builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)
builder.add_node("analyze", analyze)
builder.add_node("respond", respond)
builder.add_edge(START, "analyze")
builder.add_edge("analyze", "respond")
builder.add_edge("respond", END)
graph = builder.compile()
print(graph.invoke({"user_query": "Where's my refund?"}))
Notice the printed result contains only draft_answer — internal_notes, real and genuinely used internally by analyze, never appears in what the caller actually receives, because OutputState deliberately doesn’t include it. This is a real, practical way to keep a workflow’s messy internal bookkeeping separate from the clean, minimal interface the rest of your application actually needs to see.
The real, well-documented failure this module exists to prevent
It’s worth telling this honestly, because it’s not a hypothetical, edge-case risk — it’s one of the most commonly reported ways real AI agents actually fail once they leave a demo and reach production. The pattern is genuinely well-documented enough to have its own recognizable shape: a team builds an agent on a Friday afternoon, demos it Monday morning, and it works beautifully — qualifying leads, booking meetings, generating proposals on the fly. It ships. By the following Wednesday, real customers are asking, “why does the bot keep asking me my company name when I already told it?” By Friday, engineers are debugging why it booked a meeting for the wrong date. By the following Monday, the team has quietly rolled it back.
The actual root cause, every time, traces back to exactly what this module has been building toward: information genuinely getting lost between steps, because it was never explicitly captured in a real, structured state — passed as loose, unstructured text instead, or simply never carried forward from one step to the next at all. Recall this module’s own core lesson from Example 3: a node’s return value merges into state, and whatever isn’t explicitly returned stays as it was. The real, documented version of getting this wrong isn’t a subtle academic concern — it’s a company’s name being asked for twice, or a meeting landing on the wrong date, in front of an actual paying customer.
flowchart LR
A["Without explicit state:\ninformation exists only\ninside one step's own output"] --> B["Next step never receives it\n(or receives an unstructured,\nlossy summary of it)"]
B --> C["Customer repeats themselves,\nwrong dates get booked,\nsilent production rollback"]
D["With explicit, typed state:\nevery field genuinely persists\nacross every node"] --> E["Each node reads exactly\nwhat it needs, reliably"]
This is precisely the discipline this entire module has walked through, deliberately: a real, typed schema, a clear understanding of what merges and what doesn’t, and fields modeled explicitly rather than left to survive — or not — inside a model’s own, informal handling of context.
Common mistakes worth avoiding
Returning the entire state from every node, out of habit. Recall Example 3’s core lesson — this is unnecessary and genuinely risks reintroducing stale data if a node computes an outdated copy of a field another node already updated more recently. Return only what actually changed.
Forgetting that a missing NotRequired field genuinely isn’t the same as an empty string or zero. Recall Example 4 — code that assumes documents always exists, even as an empty list, will raise a real KeyError the moment it encounters a state where that field was genuinely never set. Check for its presence deliberately.
Mixing up TypedDict’s static checking with Pydantic’s real, runtime validation. Recall Example 8 — a TypedDict field typed as int will not stop you from actually passing a string at runtime; only your editor’s type checker complains, and only if you’re using one. Pydantic genuinely raises an error the moment invalid data is constructed. Choose deliberately based on whether you need that real, runtime guarantee.
What you should take away from this module
- State is the concrete, inspectable memory of one workflow run — not an abstraction, but a real, specific set of fields like the eight named at the top of this module.
- A node’s return value is merged into existing state, field by field — only return what genuinely changed.
NotRequiredfields, nestedTypedDicts,Literalstatus flags, and counters all combine to model genuinely realistic, real-world workflow data.- Pydantic offers real, runtime validation where
TypedDictonly offers static type hints — a genuine, deliberate choice, not a stylistic one. - Separate
input_schemaandoutput_schemalet you keep a workflow’s internal bookkeeping cleanly hidden from whoever actually calls the graph.
Where this goes next
The next module covers Nodes properly — the real units of work state flows through, with concrete examples spanning pure Python, LLM calls, tools, retrievers, business APIs, and even a genuine human-decision point.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed