LangChain RunnableParallel returning wrong structure — dict shape confusion (2026) — Fix Guide (2026) | AI Error Hub
Home Providers LangChain RunnableParallel shape
LangChain LCEL · RunnableParallel Severity: Medium HTTP n/a

LangChain RunnableParallel — dict output shape not matching downstream

<code>RunnableParallel</code> fans out to multiple branches and merges their results into a dict. The confusion begins when a downstream step expects one specific branch instead of the whole dict.

By Sana K. · Cloud AI Reliability Engineer Published 2026-07-26 Verified 2026-07-26

Quick fix (TL;DR)

Resolution: RunnableParallel({"a": chain_a, "b": chain_b}) runs both branches on the same input and returns {"a": result_a, "b": result_b}. Downstream steps must consume that dict shape. Fix by (a) matching branch names to downstream prompt variables, (b) using itemgetter to extract single fields, and (c) piping into RunnablePassthrough.assign() for the classic RAG pattern where context and question travel together.

Real error messages you'll see

These are the exact strings returned by the LangChain framework and its integrations when this error occurs. Copy-paste-searching any of them should land on this page.

Prompt cannot find expected variables
KeyError: 'context'
# RunnableParallel returned {"docs": [...], "question": "..."}
# but the prompt template expects {context} not {docs}
Downstream lambda receives dict when it expected a value
TypeError: expected str or list, got dict
# Chained RunnableParallel | some_function — the function got the whole
# {a: ..., b: ...} dict when it wanted just one branch's result
Async gather partial failure
asyncio.exceptions.CancelledError: One branch of RunnableParallel raised;
all other branches were cancelled.

Reference

RunnableParallel shape flow

StageShapeNotes
Input to parallelanythingSame input goes to every branch
Each branchruns independentlyConcurrent execution (async under the hood)
Merged outputdictOne key per branch, value is that branch's output
Downstream consumerexpects the dictOr extract a field via itemgetter

Common RunnableParallel patterns

PatternShapeUse case
Fan-out for retrieval + passthrough{"context": docs, "question": q}RAG
Multi-model comparison{"gpt": ..., "claude": ...}A/B model outputs
Multi-tool call in parallel{"weather": ..., "news": ...}Agent-style multi-source
Enrichment then merge{"raw": input, "enriched": ...}Data pipelines

Root causes, ranked by frequency

Based on developer reports across LangChain forums, GitHub issues, and Discord community during 2025–2026.

  • 28%
    Branch names do not match downstream prompt variables. Parallel emits {"docs": ...}; prompt template uses {context}.
  • 18%
    Downstream step expects a value but gets a dict. Chained a scalar-consuming function after RunnableParallel.
  • 14%
    Confusion with RunnablePassthrough.assign vs RunnableParallel. .assign() ADDS keys to an existing dict; Parallel REPLACES the input with a new dict.
  • 10%
    One branch fails; async cancels others. Loss of partial results if branch reliability differs.
  • 8%
    Nested Parallel emits dict-of-dicts. Not always what the developer expected.
  • 7%
    Shorthand dict syntax not recognised. Some LangChain versions accept a bare dict as an implicit RunnableParallel; others require the explicit constructor.
  • 7%
    Async branch raises but sync invoke swallows details. Traces show the parallel wrapper, hiding the underlying branch error.
  • 8%
    Branch order not deterministic. When one branch depends on another's side effects (bad design but common), race conditions appear.

Fixes — copy-paste solutions

Fix #1

Match branch names to downstream prompt variables exactly

The single most common Parallel error is a name mismatch.

When the output of RunnableParallel feeds into a prompt template, branch names must exactly match the template variables. Rename branches or templates to align.

aligned_names.py
from operator import itemgetter
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4o-mini")

# Prompt uses {context} and {question}
prompt = ChatPromptTemplate.from_template(
    "Given this context:\n{context}\n\nAnswer: {question}"
)

def retrieve(question: str) -> str:
    # Your vector store or search
    return "Relevant docs about " + question

# ❌ WRONG — branch names do not match template variables
# parallel_wrong = RunnableParallel(docs=retrieve, q=RunnablePassthrough())
# chain = parallel_wrong | prompt | model    # KeyError: 'context'

# ✓ RIGHT — branch names match {context} and {question}
parallel = RunnableParallel(
    context=itemgetter("question") | RunnableLambda(retrieve),
    question=itemgetter("question"),   # or RunnablePassthrough() if input is str
)

chain = parallel | prompt | model | StrOutputParser()
print(chain.invoke({"question": "What is LCEL?"}))

# ✓ CLEANER — RunnablePassthrough.assign() adds context, keeps question
chain2 = (
    RunnablePassthrough.assign(
        context=lambda x: retrieve(x["question"])
    )
    | prompt
    | model
    | StrOutputParser()
)
print(chain2.invoke({"question": "What is LCEL?"}))
The RunnablePassthrough.assign() form is usually cleaner than explicit RunnableParallel for the "add a field to existing dict" pattern. Reserve Parallel for genuine fan-out to independent branches.
Fix #2

Use itemgetter to extract single fields from parallel output

Standard Python <code>operator.itemgetter</code> composes nicely into LCEL.

When you need one branch's result but the parallel emits many, use itemgetter("branch_name") as the next step to extract just that value.

itemgetter_pattern.py
from operator import itemgetter
from langchain_core.runnables import RunnableParallel
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4o-mini")

# Fan out to two independent processes
parallel = RunnableParallel(
    summary=summarise_chain,
    keywords=keywords_chain,
)

# Downstream we only need the summary — extract with itemgetter
summary_only_chain = parallel | itemgetter("summary")

# For downstream that needs both, keep the dict shape
both_chain = parallel | some_step_that_takes_dict

# Combining multiple itemgetters
def render(x):
    return f"Summary: {x['summary']}\n\nKeywords: {', '.join(x['keywords'])}"

rendered_chain = parallel | render

# When you need to reshape a dict:
reshape_chain = (
    parallel
    | (lambda x: {"final_summary": x["summary"], "final_tags": x["keywords"]})
)

# For very long-form transformations, use RunnableLambda for tracing benefits
from langchain_core.runnables import RunnableLambda
labeled_chain = parallel | RunnableLambda(
    lambda x: {"final_summary": x["summary"], "final_tags": x["keywords"]},
    name="reshape_output",
)
Wrap complex reshaping in named RunnableLambda — the name appears in LangSmith traces, making debugging much easier than seeing anonymous lambda steps.
Fix #3

Handle partial failures in parallel branches

One flaky branch should not kill the whole request.

Use Runnable.with_fallbacks() per branch, or catch exceptions in the branch's wrapping lambda. LangChain 0.2+ also provides RunnableParallel configuration for error handling.

parallel_resilience.py
from langchain_core.runnables import RunnableParallel, RunnableLambda

# Flaky retrieval — wrap it so failures return an empty result instead of throwing
def safe_retrieve(question: str) -> str:
    try:
        return retrieve(question)
    except Exception as e:
        # Log to your observability stack
        print(f"[retrieve failed] {e}")
        return ""  # graceful degradation — chain continues with empty context

# Per-branch fallback using with_fallbacks
from langchain_core.runnables import RunnableLambda

primary_retrieve = RunnableLambda(retrieve)
fallback_retrieve = RunnableLambda(lambda q: "")

resilient_retrieve = primary_retrieve.with_fallbacks([fallback_retrieve])

parallel = RunnableParallel(
    context=itemgetter("question") | resilient_retrieve,
    question=itemgetter("question"),
)

# If retrieve fails, context becomes "" and the chain still runs
chain = parallel | prompt | model | StrOutputParser()

# For async chains, be aware: one branch raising will cancel the others
# unless you set return_exceptions=True in the underlying gather.
# The simplest defense is to make each branch self-recovering as above.
In production, treat every parallel branch as potentially flaky. Even calls to internal services fail during deploys, scale events, or network hiccups. Graceful degradation is not optional at scale.

Prevention checklist

Ship these seven safeguards once and this error stops appearing in your logs.

  • Match RunnableParallel branch names to downstream prompt template variables exactly.
  • Prefer RunnablePassthrough.assign() when the intent is "add a field to a dict", not fan out.
  • Use itemgetter or named RunnableLambda to extract single fields cleanly.
  • Add .with_fallbacks() to every unreliable branch (retrieval, external APIs).
  • Name every non-trivial lambda so LangSmith traces are readable.
  • For nested Parallel, sketch the shape at each depth before writing code — dict-of-dicts confuses fast.
  • Test parallel chains with a "one branch always fails" test case; verify graceful degradation.

Frequently asked questions

Yes — via asyncio.gather under the hood. Sync invoke uses a thread pool. For CPU-bound branches this gives near-linear speedup; for I/O-bound branches (typical for RAG) the benefit is dominant.
Parallel when branches are truly independent (fetch from A, fetch from B, both use the same input). Sequential .assign() when later steps need earlier results. Sequential is easier to read; parallel is faster.
Yes in most LangChain versions — a dict literal in the LCEL chain is coerced to RunnableParallel. Example: {"context": retrieve, "question": passthrough} | prompt | model. Explicit is clearer but the sugar is idiomatic.
Enable LangSmith tracing — each branch appears as its own span with duration. Alternatively, wrap each branch in a lambda that logs timing.
Bounded by your event loop, not LangChain. For heavy fan-outs (>100 branches), use RunnableParallel's max_concurrency config or batch the work with abatch().

Get the weekly AI-error digest

New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.