What Are LangGraph Subgraphs and Why They Matter
Single-graph agents break down fast. Once your workflow exceeds ~10 nodes, the state schema bloats, edge logic becomes hard to trace, and you can't reuse anything across projects.
LangGraph subgraphs solve this by letting you treat any compiled graph as a node inside a parent graph. Each subgraph owns its own state, runs its own logic, and exposes only the fields the parent needs. The result is a composable agent architecture — the same way microservices decompose monoliths, subgraphs decompose monolithic agent graphs.
You'll learn:
- How subgraph state isolation works and why it matters
- How to wire a subgraph into a parent graph with state transformers
- How to pass context down and results up across graph boundaries
- A production pattern: a research agent built from two reusable subgraphs
Time: 25 min | Difficulty: Advanced
How LangGraph Subgraphs Work
A subgraph is a CompiledGraph used as a node. When the parent graph invokes it, LangGraph:
- Maps parent state fields into the subgraph's input schema
- Runs the subgraph to completion
- Maps the subgraph's output state back into parent state
The key insight: subgraph state is private by default. The parent can only see what you explicitly expose. This prevents state key collisions and keeps each subgraph independently testable.
Parent Graph State
├── shared_field_a
├── shared_field_b
│
└─── [subgraph node] ──────────────────────┐
Subgraph State (isolated) │
├── internal_field_1 │
├── internal_field_2 │
└── output_field ────────────────▶ maps back to parent
LangGraph handles the boundary crossing through state transformers — functions you write to translate between schemas at entry and exit.
Setting Up: Install and Imports
# LangGraph 0.3+ required for stable subgraph support
pip install langgraph>=0.3.0 langchain-openai>=0.2.0 --break-system-packages
# Verify
python -c "import langgraph; print(langgraph.__version__)"
Expected: 0.3.x
from langgraph.graph import StateGraph, END
from langgraph.graph.state import CompiledStateGraph
from langchain_openai import ChatOpenAI
from typing import TypedDict, Annotated
import operator
Step 1: Define Independent Subgraph State
Each subgraph needs its own TypedDict state. Keep it narrow — only the fields that subgraph actually needs.
# Subgraph A: web research
class ResearchState(TypedDict):
query: str
search_results: list[str]
summary: str
# Subgraph B: fact checking
class FactCheckState(TypedDict):
claims: list[str]
verdicts: list[dict]
confidence: float
The parent graph has its own broader state:
class ParentState(TypedDict):
user_question: str
research_summary: str # will be filled by ResearchSubgraph
fact_check_report: dict # will be filled by FactCheckSubgraph
final_answer: str
Step 2: Build the Research Subgraph
Build subgraphs exactly like any LangGraph graph — nodes, edges, compile().
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def search_web(state: ResearchState) -> ResearchState:
# In production: call a real search tool (Tavily, SerpAPI, etc.)
# Here we simulate for clarity
query = state["query"]
results = [
f"Result 1 for: {query}",
f"Result 2 for: {query}",
f"Result 3 for: {query}",
]
return {"search_results": results}
def summarize_results(state: ResearchState) -> ResearchState:
results_text = "\n".join(state["search_results"])
response = llm.invoke(
f"Summarize these search results in 3 bullet points:\n{results_text}"
)
return {"summary": response.content}
# Build and compile the subgraph
research_builder = StateGraph(ResearchState)
research_builder.add_node("search", search_web)
research_builder.add_node("summarize", summarize_results)
research_builder.set_entry_point("search")
research_builder.add_edge("search", "summarize")
research_builder.add_edge("summarize", END)
research_subgraph: CompiledStateGraph = research_builder.compile()
Test it in isolation before wiring it into a parent — this is the key advantage of subgraph composition:
# ✅ Test subgraph standalone
result = research_subgraph.invoke({"query": "LangGraph 0.3 new features"})
print(result["summary"])
Step 3: Build the Fact-Check Subgraph
def extract_claims(state: FactCheckState) -> FactCheckState:
# Extract individual claims from incoming text
# In production: use structured output / tool call
claims = state.get("claims", [])
return {"claims": claims}
def verify_claims(state: FactCheckState) -> FactCheckState:
verdicts = []
for claim in state["claims"]:
response = llm.invoke(
f"Is this claim accurate? Answer JSON: {{\"claim\": str, \"verdict\": \"true|false|uncertain\", \"reason\": str}}\n\nClaim: {claim}"
)
verdicts.append({"claim": claim, "raw_response": response.content})
# Simple confidence: fraction of non-uncertain verdicts
confidence = len([v for v in verdicts if "uncertain" not in v["raw_response"]]) / max(len(verdicts), 1)
return {"verdicts": verdicts, "confidence": confidence}
factcheck_builder = StateGraph(FactCheckState)
factcheck_builder.add_node("extract", extract_claims)
factcheck_builder.add_node("verify", verify_claims)
factcheck_builder.set_entry_point("extract")
factcheck_builder.add_edge("extract", "verify")
factcheck_builder.add_edge("verify", END)
factcheck_subgraph: CompiledStateGraph = factcheck_builder.compile()
Step 4: Wire Subgraphs into the Parent Graph
This is where state transformers do the work. Each node that wraps a subgraph must:
- Transform parent state → subgraph input before invoking
- Transform subgraph output → parent state before returning
def run_research(state: ParentState) -> ParentState:
# Transform: parent state → subgraph input
subgraph_input = {
"query": state["user_question"],
"search_results": [],
"summary": "",
}
# Run the subgraph
subgraph_output = research_subgraph.invoke(subgraph_input)
# Transform: subgraph output → parent state update
return {"research_summary": subgraph_output["summary"]}
def run_fact_check(state: ParentState) -> ParentState:
# Extract claims from the research summary
# In production: use an LLM call to extract structured claims
summary = state["research_summary"]
claims = [line.strip("• ").strip() for line in summary.split("\n") if line.strip()]
subgraph_input = {
"claims": claims,
"verdicts": [],
"confidence": 0.0,
}
subgraph_output = factcheck_subgraph.invoke(subgraph_input)
return {
"fact_check_report": {
"verdicts": subgraph_output["verdicts"],
"confidence": subgraph_output["confidence"],
}
}
def generate_answer(state: ParentState) -> ParentState:
confidence = state["fact_check_report"]["confidence"]
summary = state["research_summary"]
prompt = (
f"Based on this research summary:\n{summary}\n\n"
f"Fact-check confidence: {confidence:.0%}\n\n"
f"Answer the original question: {state['user_question']}"
)
response = llm.invoke(prompt)
return {"final_answer": response.content}
Now assemble the parent graph:
parent_builder = StateGraph(ParentState)
parent_builder.add_node("research", run_research)
parent_builder.add_node("fact_check", run_fact_check)
parent_builder.add_node("answer", generate_answer)
parent_builder.set_entry_point("research")
parent_builder.add_edge("research", "fact_check")
parent_builder.add_edge("fact_check", "answer")
parent_builder.add_edge("answer", END)
agent = parent_builder.compile()
Step 5: Native Subgraph Node (LangGraph 0.3+)
LangGraph 0.3 adds add_node support for compiled graphs directly — no wrapper function needed when your state schemas share key names.
# If parent state has keys that match subgraph state exactly,
# LangGraph handles mapping automatically
parent_builder.add_node("research", research_subgraph)
This works cleanly when schemas overlap. Use the wrapper function approach (Step 4) when schemas diverge — it's more explicit and easier to debug.
Verification
result = agent.invoke({
"user_question": "What are the main new features in LangGraph 0.3?",
"research_summary": "",
"fact_check_report": {},
"final_answer": "",
})
print("Research Summary:")
print(result["research_summary"])
print("\nFact Check Confidence:", result["fact_check_report"]["confidence"])
print("\nFinal Answer:")
print(result["final_answer"])
You should see: A structured answer backed by summarized research and a confidence score for the claims made.
To visualize the graph topology:
# Requires pygraphviz: pip install pygraphviz --break-system-packages
print(agent.get_graph(xray=True).draw_ascii())
The xray=True flag expands subgraphs in the ASCII output — you'll see each subgraph's internal nodes inline.
Production Considerations
State schema drift is the most common failure. When you update a subgraph's output schema, any parent transformer that reads old field names silently returns None. Always version your subgraph state TypedDicts and add assertions in transformers:
def run_research(state: ParentState) -> ParentState:
output = research_subgraph.invoke(...)
# Guard against schema drift — fail loud, not silent
assert "summary" in output, f"research_subgraph output missing 'summary': {output.keys()}"
return {"research_summary": output["summary"]}
Parallel subgraph execution is possible using LangGraph's Send API. Fan out to multiple subgraphs simultaneously and merge results in a reducer node — useful when subgraphs are independent (e.g., running research and a separate document retrieval subgraph in parallel).
Checkpointing works at the parent level. If you add a MemorySaver or SqliteSaver to the parent compile() call, LangGraph saves state at each parent node boundary — including before and after each subgraph call. Subgraph-internal state is not checkpointed separately unless you compile subgraphs with their own checkpointers.
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("agent_memory.db") as checkpointer:
agent = parent_builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "session-42"}}
result = agent.invoke({...}, config=config)
Testing strategy: test each subgraph in isolation first (research_subgraph.invoke(...)), then test the parent with mocked subgraph outputs. Don't debug the full system before each subgraph passes its own unit tests.
Summary
- Subgraphs are compiled graphs used as nodes — they own private state and only expose what you map out
- State transformers are the seam between parent and subgraph: explicit, testable, and the first place to look when things break
- LangGraph 0.3 supports direct
add_node(subgraph)when schemas share key names - Each subgraph should be independently runnable and testable before wiring into a parent
- Checkpointing lives at the parent level; subgraph-internal state is ephemeral unless you opt in
Composable subgraphs pay off in proportion to how many agents you build. The upfront cost is the schema design work — get that right and you'll reuse the same research, retrieval, or validation subgraph across a dozen parent graphs.
Tested on LangGraph 0.3.2, langchain-openai 0.2.14, Python 3.12, Ubuntu 24.04