← Back to Learning Hub

Subgraphs and Graph Composition

LangGraphDurable ExecutionAdvanced21 min

By: Anacodic Team

TL;DR

compile() returns a Runnable, so a compiled graph can be added as a node in another graph — that is the whole mechanism, and everything else follows from one question: do the two state schemas share keys? If they do, pass the compiled subgraph straight to add_node and LangGraph merges the shared keys automatically. If they do not, wrap it in a function that translates the parent's state in and the subgraph's result back out. The trap is that sharing keys is convenient and creates coupling: the subgraph is no longer reusable anywhere the parent's key names do not exist, and it can write parent keys you did not intend to expose. Subgraphs are also opaque by default — streaming, state history, and debugging show one node until you pass subgraphs=True, which is why a composed graph is harder to observe than a flat one and why you should not nest without a reason.


Simple explanation + analogy

A subgraph is a function call, and every intuition you have about functions transfers.

Which makes the two integration styles familiar:

  • Shared state schema is a function that reads and writes global variables. No arguments to pass, no return value to unpack — it just operates on the same names the caller uses. Convenient, and it couples the two: the function only works where those globals exist, and it can clobber any of them.
  • Translated schema is a function with explicit parameters and a return value. You marshal inputs in and results out. Slightly more code, and the callee knows nothing about its caller, so it is reusable anywhere.

Almost everyone reaches for globals first because it is less typing, and discovers the cost when they want the same subgraph in a second parent that names things differently.

The analogy has one important gap. A normal function call is invisible to your infrastructure — a profiler shows you the stack. A LangGraph subgraph is invisible by default: streaming and history report the parent's node, not the steps inside it. So a composed graph is genuinely harder to observe than a flat one until you explicitly ask to see through the boundary. That is a real reason to keep nesting shallow — not aesthetics.


Diagram

  THE REUSABLE UNIT — compiled once, used in two different parents
  ═══════════════════════════════════════════════════════════════════
   clean:  START ─▶ [strip_markup] ─▶ [tokenize] ─▶ END
           state: {raw, text, tokens}


  CASE 1 — SHARED KEYS: add the compiled graph directly as a node
  ═══════════════════════════════════════════════════════════════════
   parent state: {raw, text, tokens, n_unique}      ◀── contains raw/text/tokens
                     ▲     ▲    ▲
                     └─────┴────┴── the same names the subgraph uses

   START ─▶ [ clean ] ─▶ [count_unique] ─▶ END
             ^^^^^^^
             the compiled subgraph IS the node; keys merge automatically

   "<b>the</b> cat the mat 7"  ─▶  tokens=[the,cat,the,mat]  ─▶  n_unique=3


  CASE 2 — DIFFERENT KEYS: wrap and translate
  ═══════════════════════════════════════════════════════════════════
   parent state: {body, word_count}                 ◀── NO shared key names
                  │           ▲
                  │           │
   START ─▶ [ clean_wrapper ] ┴─▶ END
              │            ▲
              │            │
        {raw: body}   {word_count: len(tokens)}
              │            │
              ▼            │
        clean.invoke() ────┘        the subgraph never learns the parent's schema


  OBSERVABILITY — the boundary is opaque unless you ask
  ═══════════════════════════════════════════════════════════════════
   stream(...)                     ─▶  ['clean']          ◀── 2 events
                                       ['count_unique']       inner steps hidden

   stream(..., subgraphs=True)     ─▶  ns=('clean:<uuid>',) node=['strip_markup']
                                       ns=('clean:<uuid>',) node=['tokenize']
                                       ns=()               node=['clean']
                                       ns=()               node=['count_unique']
                                                               ◀── 4 events, namespaced

How it works (deep)

1. A compiled graph is a node

clean = sub_builder.compile()
parent.add_node("clean", clean)      # not a function — a compiled graph

Because compile() returns a Runnable implementing invoke/stream/ainvoke, the parent calls it exactly as it calls any node. There is no special composition API; the uniform interface is the composition mechanism.

2. Shared keys merge; the rest is invisible

When schemas overlap, LangGraph passes the shared keys in and merges the subgraph's updates back through the parent's reducers. Two consequences worth being deliberate about.

Keys the parent has and the subgraph does not are simply not visible inside it — that is fine, and a mild form of encapsulation. But keys the subgraph declares that the parent also has are writable by the subgraph, whether you intended that or not. A subgraph with a status field will overwrite the parent's status field on every run, silently, because the names happen to match. Naming collisions across a boundary you did not design are a genuine source of confusing bugs, and they get more likely as both schemas grow.

3. Translation is the reusable shape

When schemas differ, wrap:

def run_clean(state: ReviewState) -> dict:
    inner = clean.invoke({"raw": state["body"], "text": "", "tokens": []})
    return {"word_count": len(inner["tokens"])}

Three lines, and they buy real properties. The subgraph is reusable in any parent, since it knows nothing about ReviewState. The interface is explicit, so a reader sees precisely what crosses the boundary. And you can only write the keys the wrapper returns, which means no accidental clobbering.

The cost is that the wrapper is a plain node calling invoke directly, so the parent's checkpointer does not checkpoint the subgraph's internal steps — the inner run is one atomic step from the parent's perspective. For a short deterministic subgraph that is usually what you want. For a long or expensive one it means a crash inside loses the whole inner run, and you should probably not be hiding it behind a wrapper.

4. Observability: subgraphs=True

By default a subgraph is one node in every observation surface. Pass subgraphs=True to stream through the boundary:

for ns, ev in parent.stream(start, stream_mode="updates", subgraphs=True):
    ...

Events arrive as (namespace, payload). The namespace is a tuple — () for the parent, ('clean:<uuid>',) for the subgraph instance — and the UUID suffix identifies the instance, so a subgraph invoked twice is distinguishable. It differs on every run, which matters if you are tempted to parse it: treat it as an opaque instance id and match on the prefix before the colon.

Note the ordering in the real output below: both inner events arrive before the parent's clean event, because the parent's node has not completed until its subgraph has. Reading that ordering as "the subgraph ran after the node" is backwards, and it confuses people reading a nested trace for the first time.

5. Checkpointing across a boundary

A subgraph added directly as a node inherits the parent's checkpointer — you do not compile it with its own, and doing so is usually a mistake. Its state is namespaced under the parent's thread, so inner steps get their own checkpoints and a crash inside can resume inside.

interrupt() inside a subgraph propagates up: the parent's invoke returns with __interrupt__, and Command(resume=...) on the parent thread resumes the inner node. The pause point is nested, but the API is the same, which is the main reason to prefer direct embedding over a wrapper when a subgraph contains a human gate.

6. Routing out of a subgraph

goto resolves within the current graph, so a subgraph node cannot name a parent node directly. To redirect the parent:

return Command(goto="escalate", graph=Command.PARENT)

Without graph=Command.PARENT you get a "node not found" error for a node that plainly exists — in the parent. This is the most common confusion in composed graphs.

7. When not to compose

Nesting has an observability cost, so it needs to buy something. Three cases where it does: genuine reuse across parents, a team boundary where one group owns a pipeline stage, and encapsulation of a complex loop whose internals the parent should not see.

A single-use subgraph that exists only to make a diagram look tidier is a net loss — it hides steps from streaming and history while adding a schema boundary to maintain. Depth compounds this: three levels of nesting means namespaces three tuples deep and a trace that is hard to follow. Prefer one level, and prefer many nodes in a flat graph over few nodes in a deep one.


The math

The reuse argument is about how much you write and maintain, and it turns on whether the parents' schemas agree.

  P  = parents needing the unit
  s  = nodes inside the unit
  w  = lines in a translation wrapper       (~3)

  duplicate the nodes in each parent:   N_dup  = P * s        nodes to maintain
  shared subgraph, shared schema:       N_sh   = s            + P name agreements
  shared subgraph, translated:          N_tr   = s + P * w    nodes+wrapper lines

  a fix to the unit's logic must be applied in:
    duplicated    P places
    shared        1 place

Observability cost, counting events at one nesting level:

  flat graph            E_flat = n                    (n nodes)
  composed, default     E_comp = n_outer              (subgraph = 1 event)
  composed, subgraphs=True
                        E_deep = n_outer + n_inner    (+ a namespace to interpret)

Worked example

A cleaning unit of s = 4 nodes needed by P = 3 parents whose schemas do not agree.

  duplicate:      N_dup = 3 * 4          = 12 nodes, and 3 edits per logic fix
  translated:     N_tr  = 4 + 3 * 3      = 4 nodes + 9 wrapper lines, 1 edit per fix

  maintenance ratio on a fix:  3 -> 1     = 3x fewer places to change

And the observability trade on the runnable example (n_outer = 2, n_inner = 2):

  flat equivalent                       E = 4 events
  composed, default                     E = 2 events    -> 50% of the steps hidden
  composed, subgraphs=True              E = 4 events    + namespaces to parse

That 50% is the honest cost of a boundary, and it is exactly what the run below prints — two events by default, four with subgraphs=True. Composition trades visibility for reuse. With P = 3 the trade is clearly worth it; at P = 1 you are paying it for nothing.


Real code

One cleaning subgraph, reused in two parents — one sharing its schema, one translating.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

# ---------- the reusable unit ----------
class CleanState(TypedDict):
    raw: str
    text: str
    tokens: list[str]

def strip_markup(state: CleanState) -> dict:
    return {"text": state["raw"].replace("<b>", "").replace("</b>", "").strip()}

def tokenize(state: CleanState) -> dict:
    return {"tokens": [t for t in state["text"].lower().split() if t.isalpha()]}

sub = StateGraph(CleanState)
sub.add_node("strip_markup", strip_markup); sub.add_node("tokenize", tokenize)
sub.add_edge(START, "strip_markup"); sub.add_edge("strip_markup", "tokenize")
sub.add_edge("tokenize", END)
clean = sub.compile()

print("subgraph alone:", clean.invoke({"raw": "  <b>Hello</b> World 42 ", "text": "", "tokens": []})["tokens"])

# ---------- CASE 1: shared keys -> add the compiled graph as a node ----------
class IndexState(TypedDict):
    raw: str
    text: str
    tokens: list[str]
    n_unique: int

def count_unique(state: IndexState) -> dict:
    return {"n_unique": len(set(state["tokens"]))}

p1 = StateGraph(IndexState)
p1.add_node("clean", clean)             # the compiled subgraph IS a node
p1.add_node("count_unique", count_unique)
p1.add_edge(START, "clean"); p1.add_edge("clean", "count_unique")
p1.add_edge("count_unique", END)
indexer = p1.compile()
out1 = indexer.invoke({"raw": "<b>the</b> cat the mat 7", "text": "", "tokens": [], "n_unique": 0})
print("case 1 (shared keys):", out1["tokens"], "unique =", out1["n_unique"])

# ---------- CASE 2: different keys -> wrap and translate ----------
class ReviewState(TypedDict):
    body: str            # note: NOT called "raw"
    word_count: int

def run_clean(state: ReviewState) -> dict:
    inner = clean.invoke({"raw": state["body"], "text": "", "tokens": []})
    return {"word_count": len(inner["tokens"])}     # map subgraph output back out

p2 = StateGraph(ReviewState)
p2.add_node("clean_wrapper", run_clean)
p2.add_edge(START, "clean_wrapper"); p2.add_edge("clean_wrapper", END)
reviewer = p2.compile()
print("case 2 (translated) :", reviewer.invoke({"body": "<b>Great</b> product 10 out of 10", "word_count": 0}))

# ---------- seeing inside: subgraphs are opaque unless you ask ----------
start = {"raw": "<b>the</b> cat the mat 7", "text": "", "tokens": [], "n_unique": 0}
print("default stream (subgraph is one node):")
for ev in indexer.stream(start, stream_mode="updates"):
    print("  ", list(ev))
print("stream with subgraphs=True:")
for ns, ev in indexer.stream(start, stream_mode="updates", subgraphs=True):
    print(f"   ns={ns} node={list(ev)}")

assert out1["tokens"] == ["the", "cat", "the", "mat"], out1["tokens"]
assert out1["n_unique"] == 3, out1["n_unique"]
assert reviewer.invoke({"body": "<b>Great</b> product 10 out of 10", "word_count": 0})["word_count"] == 4
outer_only = [list(e)[0] for e in indexer.stream(start, stream_mode="updates")]
assert outer_only == ["clean", "count_unique"], outer_only
print("OK: one subgraph reused in two parents with different schemas")

# Output:
#   subgraph alone: ['hello', 'world']
#   case 1 (shared keys): ['the', 'cat', 'the', 'mat'] unique = 3
#   case 2 (translated) : {'body': '<b>Great</b> product 10 out of 10', 'word_count': 4}
#   default stream (subgraph is one node):
#      ['clean']
#      ['count_unique']
#   stream with subgraphs=True:
#      ns=('clean:de040cd2-08bb-f2a6-3752-305b348ae94f',) node=['strip_markup']
#      ns=('clean:de040cd2-08bb-f2a6-3752-305b348ae94f',) node=['tokenize']
#      ns=() node=['clean']
#      ns=() node=['count_unique']
#   OK: one subgraph reused in two parents with different schemas

Several details are worth reading off the real output rather than taking on trust.

word_count is 4, not 5, for "Great product 10 out of 10" — the isalpha() filter drops both 10s. The first version of this example asserted 5 and failed, which is a small demonstration of why the numeric claims in these articles are asserted rather than eyeballed.

The two streams show the opacity plainly: two events by default, four with subgraphs=True. And the ordering in the nested stream has the inner strip_markup and tokenize arriving before the outer clean event, because the parent's node is not complete until its subgraph finishes. The UUID in the namespace is a per-instance identifier and differs on every run — match on the clean: prefix, never on the whole string.

case 2's output also shows the parent's state unchanged apart from word_count: body is still there and no raw, text, or tokens key leaked into ReviewState. That containment is what the wrapper bought.


Real-world example

A document pipeline needed the same ingestion sequence — OCR, layout detection, text normalisation, chunking — in three places: the main indexing flow, a re-processing flow for documents whose OCR had been corrected, and an evaluation harness that ran ingestion over a fixed test set.

It began as copy-paste, four nodes duplicated three times. The failure was the predictable one: a fix to the chunking overlap was applied in the indexing flow and the re-processing flow, and missed in the evaluation harness. For several weeks the harness measured a chunking configuration that no longer existed in production, and the retrieval metrics it produced were quietly meaningless — the worst kind of bug, since it does not crash and it makes you trust numbers you should not.

Extracting one compiled subgraph fixed it, but the first extraction shared state keys with the indexing parent, because that was the least work. It ran fine there and would not compose into the evaluation harness at all, whose state was organised around test cases rather than a single document, and it turned out the subgraph was writing a status key the indexing parent also used for something else — a collision nobody had noticed because both meanings were vaguely compatible.

The second attempt gave the subgraph its own schema and a three-line wrapper per parent. Nine lines total, against four nodes maintained in one place, and the collision became impossible because the wrapper decides what crosses.

The observability cost showed up immediately and was worth planning for rather than avoiding. Support engineers debugging a stuck ingestion saw one ingest event in the trace and could not tell whether it was OCR or chunking that had hung. Turning on subgraphs=True for the internal trace view restored the detail. The team also decided not to nest further: an early design had chunking as a subgraph inside the ingestion subgraph, and two levels of namespacing made traces hard enough to read that flattening it back to one level was the clear improvement.


Interview questions companies actually ask

1. How do you add a subgraph to a parent graph? [easy] Pass the compiled graph to add_node. compile() returns a Runnable implementing invoke/stream, so a graph satisfies the same interface as any node — there is no special composition API, the uniform interface is the mechanism.

2. What decides whether you embed directly or wrap in a function? [medium] Whether the schemas share key names. Shared keys can be passed directly and LangGraph merges updates through the parent's reducers. Different keys need a wrapper that translates the parent's state in and maps results out, because the subgraph has no way to read fields it does not declare.

3. What is the hidden cost of sharing a state schema? [hard] Coupling in both directions. The subgraph only works in parents that use its key names, so it stops being reusable; and any key it declares that the parent also has becomes writable by it, so a status field in both silently overwrites on every run. Those collisions get more likely as both schemas grow and are hard to spot because nothing errors.

4. Why does a composed graph show fewer streaming events than a flat one? [medium] The subgraph boundary is opaque by default — the parent reports its own node completing, not the steps inside. A two-plus-two composition emits two events instead of four, hiding half the steps, until you pass subgraphs=True to stream through the boundary.

5. What does the namespace in a subgraphs=True event tell you? [medium] Which graph the event came from: () for the parent, ('clean:<uuid>',) for a subgraph instance. The UUID identifies the invocation, so a subgraph used twice is distinguishable, and it changes every run — match on the prefix before the colon and treat the rest as opaque.

6. In a nested stream, why do inner events arrive before the parent node's event? [hard] Because the parent's node has not completed until its subgraph has, and updates only emits on completion. So the inner steps finish first and the enclosing node's event follows. Reading it as "the subgraph ran after the node" is backwards and a common misreading of nested traces.

7. How does checkpointing work across a subgraph boundary? [hard] A directly embedded subgraph inherits the parent's checkpointer and its state is namespaced under the parent's thread, so inner steps get their own checkpoints and a crash inside can resume inside. A wrapper that calls invoke directly makes the whole inner run one atomic step to the parent, so a crash loses all of it — fine for something short, wrong for something long or expensive.

8. How does interrupt() inside a subgraph behave? [hard] It propagates: the parent's invoke returns with __interrupt__ and Command(resume=...) on the parent thread resumes the nested node. The pause point is nested but the API is unchanged, which is the main reason to prefer direct embedding over a wrapper when a subgraph contains a human gate.

9. Why does goto fail to find a parent node from inside a subgraph? [medium] goto resolves within the current graph, so it looks for the name among the subgraph's nodes. Use Command(goto="x", graph=Command.PARENT) to target the enclosing graph. This is the most frequent confusion in composed graphs — an error saying a node does not exist when it plainly does, one level up.

10. When is a subgraph the wrong choice? [medium] When it is used once and exists only to tidy a diagram. You pay the observability cost and the schema boundary and get no reuse. Depth makes it worse — namespaces nest and traces get hard to follow — so prefer one level, and prefer a flat graph with more nodes over a deep graph with fewer.


When to use / tradeoffs

Reach for a subgraph when:

  • The same sequence is genuinely needed by two or more parents.
  • A team or ownership boundary lines up with a pipeline stage.
  • A complex internal loop should be encapsulated so the parent cannot depend on its internals.
  • You want to test a stage in isolation — a compiled subgraph is independently invokable, as the example's first line shows.

Do NOT use when:

SituationWhy it breaksUse instead
Single use, added for tidinessPays observability + schema cost for no reuseKeep the nodes flat
Deep nesting (3+ levels)Namespaces nest; traces become unreadableFlatten to one level
Reuse wanted, but schemas shared for convenienceCouples the unit to one parent's names; key collisionsOwn schema + wrapper
Long/expensive subgraph behind a plain wrapperInner steps not checkpointed; a crash loses all of itEmbed directly
Subgraph must redirect the parent, plain gotoResolves inside the subgraph; "node not found"Command(graph=Command.PARENT)

Honest limits. The maintenance arithmetic assumes the duplicated copies would otherwise stay identical, which is the optimistic case — in practice they drift toward their parents' needs, so some of what looks like duplication is real divergence, and forcing it into one shared unit produces a subgraph full of conditional branches that is worse than either option. The observability numbers count events at one nesting level and understate the problem, because the real cost is not event count but the effort of interpreting namespaced traces under time pressure, which is when composed graphs are least pleasant. Two limits have no formula. A shared subgraph is a shared dependency, so a change to it now affects every parent, and you need the tests to know that — extraction moves risk from "forgot to update a copy" to "broke three callers at once", which is better but not free. And schema coupling degrades quietly: nothing detects the day a new parent key collides with a subgraph key, the run simply produces a slightly wrong value, so the wrapper's explicitness is worth its three lines mostly as protection against a bug you would otherwise find late.


  • A compiled graph is a node — compile() returns a Runnable, and that uniform interface is the entire composition mechanism.
  • Shared keys → pass the compiled subgraph to add_node; LangGraph merges through the parent's reducers.
  • Different keys → wrap in a function that translates in and out. More reusable, explicit, and collision-proof.
  • Sharing a schema couples the subgraph to one parent's names and lets it overwrite matching keys silently.
  • Subgraphs are opaque by default; subgraphs=True streams through the boundary with namespaced events.
  • In nested traces, inner events precede the enclosing node's event.
  • Embedded subgraphs inherit the parent's checkpointer and can be resumed inside; a wrapper makes the inner run atomic.
  • Command(graph=Command.PARENT) is required to route out of a subgraph.
  • Compose for reuse, ownership, or encapsulation — never for tidiness, and prefer one level of nesting.

Related articles in this module (6-6):

Related elsewhere:

Sources: LangGraph docs: subgraphs · LangGraph how-to: use subgraphs · LangGraph docs: streaming from subgraphs · Verified against langgraph 1.2.8; all output above is from an actual run.