Recall Module 8’s clean rule: computation belongs in a node, routing decisions belong in a separate routing function. That rule is genuinely good practice — and it also has a real, honest limitation. Some nodes need to do both: compute something and, based on what they just computed, decide exactly where the graph should go next. Splitting that into two artificially separate pieces sometimes makes the code harder to follow, not easier. Command exists for exactly this.
The problem, made concrete
Recall a genuine review step from a real revise-and-approve workflow. A review node needs to actually evaluate a draft, and the result of that evaluation directly determines whether the workflow moves forward or loops back for revision. Watch how awkward the conditional-edges version genuinely becomes.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
draft: str
review_result: str
def review(state: State) -> dict:
# pretend this genuinely evaluates the draft
return {"review_result": "approved" if len(state["draft"]) > 20 else "needs_revision"}
def route_after_review(state: State) -> str:
return "finish" if state["review_result"] == "approved" else "revise"
def revise(state: State) -> dict:
return {"draft": state["draft"] + " (revised, now longer)"}
def finish(state: State) -> dict:
return {}
builder = StateGraph(State)
builder.add_node("review", review)
builder.add_node("revise", revise)
builder.add_node("finish", finish)
builder.add_edge(START, "review")
builder.add_conditional_edges("review", route_after_review, ["finish", "revise"])
builder.add_edge("revise", "review")
builder.add_edge("finish", END)
graph = builder.compile()
print(graph.invoke({"draft": "Short.", "review_result": ""}))
This genuinely works. But notice review’s actual decision — approved or needs revision — is computed once inside review, then effectively recomputed a second time inside route_after_review, just to translate it into a routing choice. That’s two separate places holding logically the same real decision.
Example 1: the same workflow, with Command
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class State(TypedDict):
draft: str
review_result: str
def review(state: State) -> Command[Literal["finish", "revise"]]:
if len(state["draft"]) > 20:
return Command(update={"review_result": "approved"}, goto="finish")
return Command(update={"review_result": "needs_revision"}, goto="revise")
def revise(state: State) -> dict:
return {"draft": state["draft"] + " (revised, now longer)"}
def finish(state: State) -> dict:
return {}
builder = StateGraph(State)
builder.add_node("review", review)
builder.add_node("revise", revise)
builder.add_node("finish", finish)
builder.add_edge(START, "review")
builder.add_edge("revise", "review")
builder.add_edge("finish", END)
graph = builder.compile()
print(graph.invoke({"draft": "Short.", "review_result": ""}))
Notice what genuinely changed: review now returns a Command object instead of a plain dictionary, carrying both update (exactly what a normal node would return) and goto (exactly what a routing function would return) — in one single, real return value. There’s no separate route_after_review function at all anymore, and no add_conditional_edges call either. The decision lives in exactly one place, precisely where it was actually made.
Comparing both approaches directly
flowchart LR
A["Conditional edges:\nnode computes,\nSEPARATE function routes"] --> C[Two places holding\nrelated logic]
B["Command:\nnode computes AND\nroutes, together"] --> D[One place,\none real decision]
Neither approach is universally correct — this is a genuine, honest engineering trade-off, not a strict improvement in one direction. Conditional edges keep computation and routing visibly, physically separate, which some teams genuinely prefer for readability at a glance across a large graph. Command keeps a tightly coupled decision in one place, avoiding the duplication Module 8’s conditional-edges version showed. The right, honest choice depends on whether a given node’s computation and its routing decision are genuinely the same real decision, or two genuinely separate ones that only happen to interact.
Example 2: Command without changing the route
Command doesn’t require every use to change where the graph goes — you can use it purely to update state while still following normal, already-defined edges.
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from typing import TypedDict
class State(TypedDict):
log: list[str]
def audit(state: State) -> Command:
return Command(update={"log": state["log"] + ["audit passed"]})
builder = StateGraph(State)
builder.add_node("audit", audit)
builder.add_edge(START, "audit")
builder.add_edge("audit", END)
graph = builder.compile()
print(graph.invoke({"log": []}))
Here, goto is simply omitted — the graph follows the normal edge from audit to END, exactly as if this had been a plain dictionary return. Command is a genuine superset of a normal node’s return value, not a completely separate mechanism you have to fully commit to everywhere.
Common mistakes worth avoiding
Reaching for Command everywhere, out of habit, once you’ve learned it. Recall Module 8’s own genuinely clean scenarios — support routing, tool selection — where computation and routing really are separate concerns. Forcing Command into those cases re-couples logic that was genuinely clearer split apart.
Forgetting the Literal[...] type hint on a node’s return type. Recall Command[Literal["finish", "revise"]] in Example 1 — this isn’t just documentation; it’s what lets tooling and LangGraph’s own graph visualization understand which real paths a Command-returning node can actually take.
Mixing Command’s goto with a conflicting add_conditional_edges on the same node. A node returning Command(goto=...) is already deciding the next step directly — layering a separate conditional edge on top of that same node creates real, genuine ambiguity about which mechanism actually controls the path.
What you should take away from this module
Command(update=..., goto=...)lets a single node both update state and decide the next node, in one return value — no separate routing function needed.- This is a genuine, honest trade-off against conditional edges, not a strict improvement — the right choice depends on whether computation and routing are really the same decision.
Commandworks withgotoomitted too, functioning exactly like a normal node’s return value when you don’t need to override the route.
Where this goes next
The next module covers Send — for a genuinely different problem Command and conditional edges don’t solve on their own: fanning out to a number of parallel branches that isn’t known until the graph is actually running.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed