LangGraph Subgraphs, Send API & LangGraph Platform Deploy Errors
Once your graph is more than a linear pipeline, three things start to bite: subgraphs that don't see the parent's state, Send fan-out that clobbers reducers, and LangGraph Platform deploys that fail on a missing langgraph.json. Here's the definitive guide to the three.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #135
Send(node, state) fans out to multiple invocations of one node; the receiving state must have reducers for fields that will merge across branches. LangGraph Platform requires a langgraph.json at the repo root plus env-var declarations; missing either causes silent deploy failures.Real error messages you'll see
# Parent state has {messages, question} — subgraph writes {messages, docs}.
# After subgraph runs, parent state has messages (merged) but no docs.
# Root cause: parent schema has no `docs` field, so LangGraph drops it silently.
InvalidUpdateError: Concurrent updates to field 'messages' without a reducer.
at Pregel.astream() — 5 Send() targets all wrote to `messages` in parallel.
# Fix: annotate messages with add_messages reducer; without it, parallel writes conflict.
Error: Missing langgraph.json in repository root. LangGraph Platform requires a langgraph.json manifest declaring graphs, dependencies, and env vars.
# Add langgraph.json at repo root: {"dependencies": ["."], "graphs": {"agent": "./src/graph.py:app"}, "env": ".env"}.
Subgraph state-sharing rules
| Case | Behavior |
|---|---|
| Parent and subgraph share a key with same reducer | Reducer merges updates from both |
| Parent has key, subgraph doesn't | Subgraph can't read or write; key is invisible to it |
| Subgraph has key, parent doesn't | Subgraph's writes are dropped when returning to parent |
| Same key, different types | Runtime error at first update |
| Same key, different reducers | Parent's reducer wins in the outer graph |
langgraph.json fields (Platform / Cloud)
| Field | Type | Purpose |
|---|---|---|
dependencies | list[str] | Local packages to install (usually ["."]) |
graphs | dict[str, str] | Map graph name → ./path/to/module.py:variable |
env | str | Path to .env file (or list of KEY=VALUE lines) |
python_version | str | e.g. "3.12" — pin to match your local env |
dockerfile_lines | list[str] | Extra Dockerfile RUN lines for system deps |
pip_config_file | str | Optional pip config (private index, etc.) |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 23%Subgraph writes a state key the parent doesn't declare. Writes are silently dropped when control returns to the parent. Add the key to the parent schema.
- 18%
Send()fan-out to a node without reducers. Parallel branches all try to write the same field; without a reducer LangGraph raisesInvalidUpdateError. - 15%Missing
langgraph.jsonat repo root. LangGraph Platform can't discover the graph. Add the manifest and re-deploy. - 12%Environment variables not declared in
langgraph.json. API keys land asNoneat runtime; downstream calls fail with 401. - 11%Wrong graph path in
langgraph.json. Path points at a factory function that returns a graph, but Platform expects a compiledappvariable. Point to the compiled variable directly. - 9%Subgraph checkpoint namespace conflict. Two subgraphs share the same
checkpoint_ns; their state overwrites in the checkpointer. Namespace them explicitly. - 7%
Sendpayload not JSON-serializable. Fan-out payload contains a non-serializable object (open file, DB cursor). Persisting or passing across the boundary fails. - 5%Python version drift between local and Platform. Local dev on 3.13, Platform builds on 3.12 default. Type hints or stdlib features silently break at runtime.
How to fix it
Declare shared state keys in both parent and subgraph schemas
Fixes silently-dropped subgraph writes.
LangGraph subgraphs are executed with a projected state — only keys that exist in the subgraph schema are visible inside. When the subgraph returns, only keys that also exist in the parent are merged back. The rule is straightforward once you know it: every state key that flows through a subgraph must appear in both schemas, with compatible types and reducers.
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class ChildState(TypedDict):
messages: Annotated[list, add_messages]
docs: Annotated[list, add]
citations: Annotated[list, add]
def retrieve(state: ChildState) -> dict:
return {"docs": ["doc1", "doc2"]}
def cite(state: ChildState) -> dict:
return {"citations": [f"[{i}]" for i in range(len(state["docs"]))]}
child_graph = StateGraph(ChildState)
child_graph.add_node("retrieve", retrieve)
child_graph.add_node("cite", cite)
child_graph.add_edge(START, "retrieve")
child_graph.add_edge("retrieve", "cite")
child_graph.add_edge("cite", END)
child_app = child_graph.compile()
# PARENT declares docs and citations to receive them from child
class ParentState(TypedDict):
messages: Annotated[list, add_messages]
question: str
docs: Annotated[list, add]
citations: Annotated[list, add]
parent_graph = StateGraph(ParentState)
parent_graph.add_node("child", child_app)
parent_graph.add_edge(START, "child")
parent_graph.add_edge("child", END)
parent_app = parent_graph.compile()
Use Send() with reducers on every merged field
Fixes InvalidUpdateError on parallel Send fan-out.
Send(node, state) creates one execution per Send. Fan out to 5 Sends and that node runs 5 times in parallel. All results merge back into the graph state, so every field the node writes must have a reducer to combine parallel updates.
from typing import Annotated, TypedDict
from operator import add
from langgraph.types import Send
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
topics: list[str]
summaries: Annotated[list, add] # reducer required for parallel Send merge
errors: Annotated[list, add]
def fanout(state):
return [Send("summarize_one", {"topic": t}) for t in state["topics"]]
def summarize_one(state) -> dict:
try:
summary = llm.invoke(f"Summarize {state['topic']} in 20 words.").content
return {"summaries": [summary]}
except Exception as e:
return {"errors": [f"{state['topic']}: {e}"]}
def combine(state):
return {"messages": [("assistant", "\n".join(state["summaries"]))]}
graph = StateGraph(State)
graph.add_node("summarize_one", summarize_one)
graph.add_node("combine", combine)
graph.add_conditional_edges(START, fanout, ["summarize_one"])
graph.add_edge("summarize_one", "combine")
graph.add_edge("combine", END)
app = graph.compile()
# Cap fan-out via config
result = app.invoke(
{"topics": ["a", "b", "c"], "summaries": [], "errors": []},
{"max_concurrency": 10},
)
max_concurrency in the runnable config to throttle parallel Send executions — critical when the fan-out target makes rate-limited API calls.Write a complete langgraph.json for LangGraph Platform / Cloud
Fixes silent deploy failures and missing env vars.
LangGraph Platform (formerly LangGraph Cloud) reads langgraph.json at the repo root to discover graphs, install dependencies, and inject env vars. A missing or incomplete file causes builds to fail with unclear errors. Point graphs at compiled app variables (not factory functions), declare every env var, and pin the Python version.
{
"dependencies": ["."],
"graphs": {
"chat_agent": "./src/agents/chat.py:app",
"research_agent": "./src/agents/research.py:app"
},
"env": ".env",
"python_version": "3.12",
"dockerfile_lines": [
"RUN apt-get update && apt-get install -y --no-install-recommends libmagic1 && rm -rf /var/lib/apt/lists/*"
]
}
# ✅ Export a COMPILED app variable at module level
# LangGraph Platform imports this variable directly
import os
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
def _build_graph(checkpointer):
g = StateGraph(MessagesState)
g.add_node("responder", responder)
g.add_edge(START, "responder")
g.add_edge("responder", END)
return g.compile(checkpointer=checkpointer)
# Platform provides POSTGRES_URI at build/run time via the langgraph.json env
_pool = AsyncConnectionPool(
os.environ["POSTGRES_URI"],
min_size=2, max_size=20,
kwargs={"prepare_threshold": None, "autocommit": True},
open=False, # opened lazily
)
_checkpointer = AsyncPostgresSaver(_pool)
# THE variable Platform expects — top-level, compiled
app = _build_graph(_checkpointer)
# ❌ ANTI-PATTERN — Platform can't call a factory
# def build_app():
# return _build_graph(_checkpointer)
# Platform will fail with: "chat.py:app is not a compiled graph"
# ✅ Declare every env var; Platform injects these at runtime
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
POSTGRES_URI=postgresql://user:pass@host:5432/langgraph
LANGSMITH_API_KEY=lsv2_...
LANGSMITH_PROJECT=my-agent-prod
LOG_LEVEL=INFO
dockerfile_lines field is where system deps go — libmagic, tesseract, headless-chrome bits. Anything apt-get install-able. Keep it minimal to keep build times fast.Prevention checklist
- Every state key that flows through a subgraph must appear in both the parent and the child schema with compatible types and reducers.
- When using
Send, every field the receiving node writes must have a reducer — parallel writes need explicit merge semantics. - Cap
Sendfan-out (max_concurrency in config, or truncate the input list) to avoid runaway parallelism. - For LangGraph Platform deploys, always add
langgraph.jsonat repo root withdependencies,graphs, andenvdeclared. - Point
graphsentries at a compiled app variable (module.py:app), not a factory function. - Pin
python_versioninlanggraph.jsonto match local dev — silent version drift breaks type hints and stdlib features. - Declare every env var your graph reads in
langgraph.json; missing keys arrive asNoneand cause downstream 401s.
Frequently asked questions
Subgraph when the logic is reusable across multiple parent graphs, or when it has its own internal state that shouldn't leak to the parent. Inline nodes when the flow is single-use and short. Subgraphs cost a bit of ceremony (schema alignment, namespaced checkpoints) but pay back when you need to test the subgraph in isolation or reuse it.
Send(node, state) lets each parallel branch receive a different state — you can tailor the input per branch. A conditional edge returning list[str] just names the downstream nodes; they all inherit the source state as-is. Use Send when the fan-out inputs differ (e.g. one topic per branch); use list-returning conditional edges when the fan-out is symmetric.
LangGraph Platform is the managed service (formerly branded LangGraph Cloud). It runs your compiled graphs with Postgres checkpointing, streaming APIs, and observability integrated with LangSmith. Self-hosting means running your own FastAPI/etc. wrapping the compiled graph. Platform is faster to ship; self-hosting is cheaper at scale and gives you full runtime control.
Yes. Each subgraph has its own schema. What matters is the projection at the boundary: parent state → subgraph input (only shared keys visible), subgraph output → parent state (only shared keys merged). Non-shared keys are invisible / dropped. Design the schemas around what needs to cross the boundary and keep internal state internal.
Because parallel Sends see the state as it was at the moment fan-out started. If Send 3 reads state["docs"], it sees the docs available before the fan-out — not any updates Sends 1 and 2 made during their runs. Reducers merge all branches' outputs after they complete, not during. If branches need to see each other's work, they must run sequentially, not in parallel.