LangGraph Subgraphs: Build Composable Agent Architecture 2026

Learn how to use LangGraph subgraphs to compose modular, reusable agent pipelines. Covers state passing, inter-graph communication, and production patterns.

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:

  1. Maps parent state fields into the subgraph's input schema
  2. Runs the subgraph to completion
  3. 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:

  1. Transform parent state → subgraph input before invoking
  2. 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