LangGraph Entry Point, END Node & Compile-Time Errors
Compile fails because LangGraph can't figure out where the graph starts, where it ends, or spots a node with no incoming edges. These are the errors you hit in the first hour of working with LangGraph — here's the definitive walkthrough.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #123
StateGraph needs exactly one entry point (via add_edge(START, "first_node") or set_entry_point) and at least one path to END. Compile errors here are almost always (a) missing entry point, (b) an unreachable node, or (c) using string literals like "START" instead of the real sentinel constants.Real error messages you'll see
ValueError: Graph must have an entrypoint: add at least one edge from START to a node.
at StateGraph.compile()
# You added nodes and internal edges but never wired START to any of them.
ValueError: Unreachable node 'summarize'. No incoming edges from START or any reachable node.
at StateGraph.compile()
# The node exists but nothing in the graph can ever get to it. Usually a typo in an edge or forgetting an add_edge call.
AttributeError: module 'langgraph.graph' has no attribute 'START_STRING'.
# You wrote graph.add_edge("START", "node_a") instead of graph.add_edge(START, "node_a"). START and END are Python constants, not strings.
Entry point declaration — three equivalent forms
| Form | Code | Notes |
|---|---|---|
set_entry_point | graph.set_entry_point("first") | Legacy — still works, but less symmetric with edge declarations. |
add_edge(START, ...) | graph.add_edge(START, "first") | Recommended in 0.2+ — reads the same as any other edge. |
| Conditional entry | graph.add_conditional_edges(START, router, path_map) | Rare but valid — the graph branches from the very first step. |
| None (invalid) | None | ValueError at compile time. |
END declaration
| Situation | Code | Effect |
|---|---|---|
| Terminal node | graph.add_edge("last", END) | Explicit termination — recommended. |
| Router terminates | path_map={"done": END} | Conditional edge points to END on some branch. |
Command(goto=END) | From inside a node function | Dynamic termination when a node itself decides to stop. |
| No END | Only cyclic edges | Runs to recursion_limit then RuntimeError. |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 30%Missing entry point. You built the whole graph but never called
set_entry_pointoradd_edge(START, "first"). LangGraph refuses to compile because it doesn't know where to begin. - 23%Unreachable node. A node was added but no incoming edge exists. Usually the result of a typo in a downstream
add_edgeor forgetting a wire during a refactor. - 16%Used string literal
"START"or"END"instead of the constants. Python treats these as node names, not sentinels. Either the graph tries to route to a nonexistent node or nothing wires up. - 11%Multiple entry points. Both
set_entry_point("a")andadd_edge(START, "b")were called. Depending on version, LangGraph either raisesValueErroror silently uses the last one. - 8%No path to END from the entry point. The graph runs but every path is cyclic — no branch terminates. Hits recursion limit and raises
RuntimeError. - 7%Compiled twice with the same builder.
app = graph.compile()then modified the graph and calledcompile()again — some LangGraph versions cache the graph and raise or silently return the old compiled version. - 5%Entry point references a node not yet added.
graph.set_entry_point("first")called beforegraph.add_node("first", ...). Version-dependent behavior — oftenValueErrorat compile.
How to fix it
Wire START to your first node with add_edge(START, ...)
The one-line fix for "Graph must have an entrypoint".
Import the START sentinel from langgraph.graph. Then treat it exactly like any other edge source. This form is symmetric with the rest of your graph and reads more naturally than the older set_entry_point pattern.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END # <-- import the sentinels
class State(TypedDict):
messages: list
def first_node(state):
return {"messages": state["messages"] + ["hi from first"]}
def last_node(state):
return {"messages": state["messages"] + ["hi from last"]}
graph = StateGraph(State)
graph.add_node("first", first_node)
graph.add_node("last", last_node)
# THE fix — one line wires the graph's entry
graph.add_edge(START, "first")
graph.add_edge("first", "last")
graph.add_edge("last", END)
app = graph.compile()
result = app.invoke({"messages": []})
print(result["messages"])
# ['hi from first', 'hi from last']
"START" as a string. START is a Python constant exported from langgraph.graph. String literal versions will not raise a type error but will fail at compile with an unhelpful message.Detect unreachable nodes before compile
Fixes the "Unreachable node" ValueError.
An unreachable node is dead code that pollutes your graph and causes compile to fail. In production graphs this usually means you renamed a node and forgot to update one of its incoming edges. A pre-compile sanity check will surface these mistakes at the point of failure instead of two hours later.
from langgraph.graph import StateGraph, START, END
def check_reachability(builder: StateGraph) -> None:
"""Walk the graph from START; raise if any node isn't reachable."""
# LangGraph exposes internal edges via builder.edges and builder.branches
all_nodes = set(builder.nodes.keys())
reachable = set()
frontier = [START]
edges = set(builder.edges) # {(src, tgt)}
branches = builder.branches or {} # {src: {branch_name: [tgts]}}
while frontier:
current = frontier.pop()
if current in reachable:
continue
reachable.add(current)
# Static edges
for src, tgt in edges:
if src == current and tgt not in reachable:
frontier.append(tgt)
# Conditional edges
if current in branches:
for _label, targets in branches[current].items():
for t in (targets if isinstance(targets, list) else [targets]):
if t not in reachable:
frontier.append(t)
unreachable = all_nodes - reachable
if unreachable:
raise ValueError(
f"Unreachable nodes: {sorted(unreachable)}. "
"Add an edge from a reachable node, or remove the dead nodes."
)
# Use it before compile()
graph = StateGraph(State)
# ... add_node / add_edge calls ...
check_reachability(graph) # fails loudly with a clear list
app = graph.compile()
builder.edges, builder.branches) can shift between minor versions of LangGraph. If your version doesn't expose these, fall back to graph.get_graph() after compile to inspect reachability — you'll just find issues one step later.Never build the graph twice from the same instance
Fixes the "graph already compiled" silent-old-version issue.
If you're iterating on graph structure inside a Jupyter notebook or a hot-reload dev server, always start from a fresh StateGraph(State) — do not modify a compiled graph. Compiled apps are frozen; the builder is mutable but subsequent compile() calls have inconsistent behavior across versions. Wrap graph construction in a factory function.
from langgraph.graph import StateGraph, START, END
def build_app():
"""Fresh graph every call — safe to hot-reload."""
graph = StateGraph(State)
graph.add_node("retrieve", retrieve)
graph.add_node("generate", generate)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
return graph.compile()
# ✅ In a notebook or script — always call the factory
app = build_app()
result = app.invoke({"messages": []})
# ✅ In FastAPI — build once at startup, not per request
from fastapi import FastAPI
api = FastAPI()
@api.on_event("startup")
def startup():
api.state.graph = build_app()
@api.post("/chat")
def chat(payload: dict):
return api.state.graph.invoke(payload)
# ❌ ANTI-PATTERN — reusing the same builder
# graph = StateGraph(State)
# ... build ...
# app1 = graph.compile()
# graph.add_node("new", new_fn) # mutation after compile
# app2 = graph.compile() # behavior undefined across versions
State schema or mock node functions into the same builder without touching production code.Prevention checklist
- Always import
STARTandENDfromlanggraph.graph. Never use the string literals"START"or"END". - Prefer
add_edge(START, "first")overset_entry_point("first")— it's more symmetric with the rest of your edge declarations. - Every graph needs at least one path to
END. Loops without termination hit the recursion limit. - Add unreachable-node detection to CI — fail the build if any node has no incoming edge.
- Wrap graph construction in a factory function. Never mutate a compiled graph.
- For long-running services (FastAPI, Modal), compile the graph once at startup, not per request.
- Visualize the graph in tests:
assert graph.get_graph().draw_mermaid(). Catches disconnected components immediately.
Frequently asked questions
Not directly. A StateGraph has exactly one entry point. If you need conditional branching from the very start, use add_conditional_edges(START, router, path_map). That gives you multiple first nodes based on the initial state. For truly parallel entries (multiple graphs running side by side), compose them as subgraphs inside a parent that fans out from a single START.
Functionally identical in modern LangGraph. set_entry_point("first") is a shorthand that predates the START sentinel. Both compile to the same edge internally. Style-wise, add_edge(START, "first") is preferred in LangGraph 0.2+ because it looks like every other edge in your graph and doesn't require developers to remember a separate method.
Yes, unless a router terminates via path_map={"done": END} or a node returns Command(goto=END). Terminal edges are how LangGraph knows the graph is done running. Without any END edge, the graph either loops (if cyclic) or fails at compile time (if the terminal node has no outgoing edges at all). The idiom is one add_edge("final_node", END) at the bottom of your builder block.
Officially yes, but treat every call as constructing a new frozen app. Do not mutate a builder after the first compile(). If you need to iterate on the graph (in a notebook, during dev), rebuild the entire StateGraph from scratch and re-compile. This is why the factory-function pattern in Fix #3 exists — it makes the "always start fresh" invariant explicit.
Most common cause: your build script imports the graph module conditionally (behind a feature flag or environment check) and the production import path skips the file that calls set_entry_point. Second most common: you introduced add_conditional_edges from START in dev but the mapping is empty in prod. Third: your factory function has a code path that returns an uncompiled builder instead of the compiled app. Search for the compile() call and check every return before it.