LangGraph Code Review
When reviewing LangGraph code, check for these categories of issues.
Critical Issues
- State Mutation Instead of Return
BAD - mutates state directly
def my_node(state: State) -> None: state["messages"].append(new_message) # Mutation!
GOOD - returns partial update
def my_node(state: State) -> dict: return {"messages": [new_message]} # Let reducer handle it
- Missing Reducer for List Fields
BAD - no reducer, each node overwrites
class State(TypedDict): messages: list # Will be overwritten, not appended!
GOOD - reducer appends
class State(TypedDict): messages: Annotated[list, operator.add] # Or use add_messages for chat: messages: Annotated[list, add_messages]
- Wrong Return Type from Conditional Edge
BAD - returns invalid node name
def router(state) -> str: return "nonexistent_node" # Runtime error!
GOOD - use Literal type hint for safety
def router(state) -> Literal["agent", "tools", "end"]: if condition: return "agent" return END # Use constant, not string
- Missing Checkpointer for Interrupts
BAD - interrupt without checkpointer
def my_node(state): answer = interrupt("question") # Will fail! return {"answer": answer}
graph = builder.compile() # No checkpointer!
GOOD - checkpointer required for interrupts
graph = builder.compile(checkpointer=InMemorySaver())
- Forgetting Thread ID with Checkpointer
BAD - no thread_id
graph.invoke({"messages": [...]}) # Error with checkpointer!
GOOD - always provide thread_id
config = {"configurable": {"thread_id": "user-123"}} graph.invoke({"messages": [...]}, config)
State Schema Issues
- Using add_messages Without Message Types
BAD - add_messages expects message-like objects
class State(TypedDict): messages: Annotated[list, add_messages]
def node(state): return {"messages": ["plain string"]} # May fail!
GOOD - use proper message types or tuples
def node(state): return {"messages": [("assistant", "response")]} # Or: [AIMessage(content="response")]
- Returning Full State Instead of Partial
BAD - returns entire state (may reset other fields)
def my_node(state: State) -> State: return { "counter": state["counter"] + 1, "messages": state["messages"], # Unnecessary! "other": state["other"] # Unnecessary! }
GOOD - return only changed fields
def my_node(state: State) -> dict: return {"counter": state["counter"] + 1}
- Pydantic State Without Annotations
BAD - Pydantic model without reducer loses append behavior
class State(BaseModel): messages: list # No reducer!
GOOD - use Annotated even with Pydantic
class State(BaseModel): messages: Annotated[list, add_messages]
Graph Structure Issues
- Missing Entry Point
BAD - no edge from START
builder.add_node("process", process_fn) builder.add_edge("process", END) graph = builder.compile() # Error: no entrypoint!
GOOD - connect START
builder.add_edge(START, "process")
- Unreachable Nodes
BAD - orphan node
builder.add_node("main", main_fn) builder.add_node("orphan", orphan_fn) # Never reached! builder.add_edge(START, "main") builder.add_edge("main", END)
Check with visualization
print(graph.get_graph().draw_mermaid())
- Conditional Edge Without All Paths
BAD - missing path in conditional
def router(state) -> Literal["a", "b", "c"]: ...
builder.add_conditional_edges("node", router, {"a": "a", "b": "b"})
"c" path missing!
GOOD - include all possible returns
builder.add_conditional_edges("node", router, {"a": "a", "b": "b", "c": "c"})
Or omit path_map to use return values as node names
- Command Without destinations
BAD - Command return without destinations (breaks visualization)
def dynamic(state) -> Command[Literal["next", "end"]]: return Command(goto="next")
builder.add_node("dynamic", dynamic) # Graph viz won't show edges
GOOD - declare destinations
builder.add_node("dynamic", dynamic, destinations=["next", END])
Async Issues
- Mixing Sync/Async Incorrectly
BAD - async node called with sync invoke
async def my_node(state): result = await async_operation() return {"result": result}
graph.invoke(input) # May not await properly!
GOOD - use ainvoke for async graphs
await graph.ainvoke(input)
Or provide both sync and async versions
- Blocking Calls in Async Context
BAD - blocking call in async node
async def my_node(state): result = requests.get(url) # Blocks event loop! return {"result": result}
GOOD - use async HTTP client
async def my_node(state): async with httpx.AsyncClient() as client: result = await client.get(url) return {"result": result}
Tool Integration Issues
- Tool Calls Without Corresponding ToolMessage
BAD - AI message with tool_calls but no tool execution
messages = [ HumanMessage(content="search for X"), AIMessage(content="", tool_calls=[{"id": "1", "name": "search", ...}]) # Missing ToolMessage! Next LLM call will fail ]
GOOD - always pair tool_calls with ToolMessage
messages = [ HumanMessage(content="search for X"), AIMessage(content="", tool_calls=[{"id": "1", "name": "search", ...}]), ToolMessage(content="results", tool_call_id="1") ]
- Parallel Tool Calls Before Interrupt
BAD - model may call multiple tools including interrupt
model = ChatOpenAI().bind_tools([interrupt_tool, other_tool])
If both called in parallel, interrupt behavior is undefined
GOOD - disable parallel tool calls before interrupt
model = ChatOpenAI().bind_tools( [interrupt_tool, other_tool], parallel_tool_calls=False )
Checkpointing Issues
- InMemorySaver in Production
BAD - in-memory checkpointer loses state on restart
graph = builder.compile(checkpointer=InMemorySaver()) # Testing only!
GOOD - use persistent storage in production
from langgraph.checkpoint.postgres import PostgresSaver checkpointer = PostgresSaver.from_conn_string(conn_string) graph = builder.compile(checkpointer=checkpointer)
- Subgraph Checkpointer Confusion
BAD - subgraph with explicit False prevents persistence
subgraph = sub_builder.compile(checkpointer=False)
GOOD - use None to inherit parent's checkpointer
subgraph = sub_builder.compile(checkpointer=None) # Inherits from parent
Or True for independent checkpointing
subgraph = sub_builder.compile(checkpointer=True)
Performance Issues
- Large State in Every Update
BAD - returning large data in every node
def node(state): large_data = fetch_large_data() return {"large_field": large_data} # Checkpointed every step!
GOOD - use references or store
from langgraph.store.memory import InMemoryStore
def node(state, *, store: BaseStore): store.put(namespace, key, large_data) return {"data_ref": f"{namespace}/{key}"}
- Missing Recursion Limit Handling
BAD - no protection against infinite loops
def router(state): return "agent" # Always loops!
GOOD - check remaining steps or use RemainingSteps
from langgraph.managed import RemainingSteps
class State(TypedDict): messages: Annotated[list, add_messages] remaining_steps: RemainingSteps
def check_limit(state): if state["remaining_steps"] < 2: return END return "continue"
Code Review Checklist
-
State schema uses Annotated with reducers for collections
-
Nodes return partial state updates, not mutations
-
Conditional edges return valid node names or END
-
Graph has path from START to all nodes
-
Checkpointer provided if using interrupts
-
Thread ID provided in config when using checkpointer
-
Tool calls paired with ToolMessages
-
Async nodes use async operations
-
Production uses persistent checkpointer
-
Recursion limits considered for loops