skip to content

In LCEL, when do you use RunnableParallel versus RunnablePassthrough.assign?

level: middleimportance: should knowfreq 60%

answer

  1. Both fan out over the same input
  2. One replaces the shape, one merges
  3. Passthrough alone is the identity step
  4. Assign needs a dict input
  5. Branches are concurrent, and all-or-nothing on error

basics

~20 s

RunnableParallel runs several runnables on the same input and returns a dict of exactly their outputs, replacing the input. RunnablePassthrough.assign runs them the same way but merges the results into the incoming dict, keeping the original keys.

solid answer

~50 s

Both fan out over one input, and the difference is what survives. `RunnableParallel({"a": r1, "b": r2})` — which a plain dict literal in a pipe is coerced into — invokes each branch on the *same* input concurrently and returns `{"a": ..., "b": ...}`. Anything not named in the mapping is gone. `RunnablePassthrough.assign(a=r1)` requires a dict input and returns that dict *plus* the new key, so earlier fields stay available to later steps. Bare `RunnablePassthrough()` is the identity runnable: it forwards its input unchanged, which is how you carry the original value through a parallel block. In practice you reach for the parallel dict when you are constructing a fresh input shape for the next step, and for `assign` when you are progressively enriching a payload across several steps and later steps still need the earlier fields.

code

python · 11 lines
python
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough

length = RunnableLambda(lambda d: len(d["text"]))

parallel = RunnableParallel(n=length, echo=RunnablePassthrough())
print(parallel.invoke({"text": "abc", "id": 7}))
# {'n': 3, 'echo': {'text': 'abc', 'id': 7}}   <- 'id' survives only inside 'echo'

enrich = RunnablePassthrough.assign(n=length)
print(enrich.invoke({"text": "abc", "id": 7}))
# {'text': 'abc', 'id': 7, 'n': 3}             <- original keys kept

go deeper

for a junior

Know that a dict inside a chain runs each value on the same input and produces a dict of results, and that RunnablePassthrough() simply forwards the input unchanged.

for a middle

Explain the replace-versus-merge distinction: a parallel mapping returns exactly its own keys, while RunnablePassthrough.assign adds keys to the incoming dict and passes the whole dict to each assigned runnable.

for a senior

Discuss the operational side — concurrent branches with all-or-nothing failure, per-branch fallbacks for degradable steps, and keeping fan-out early so the final step can still stream to the caller.

for a principal

Treat these as data-shape contracts between stages. Decide whether a pipeline projects or accumulates, and say why: accumulating payloads are easier to extend but carry dead fields and leak more into logs and traces.

## Three related pieces **`RunnablePassthrough()`** is the identity: `invoke(x)` returns `x`. On its own it looks pointless; its use is positional. Inside a parallel mapping it is the branch that carries the untouched input forward, so `{"raw": RunnablePassthrough(), "derived": some_runnable}` gives the next step both the original and the computed value. **`RunnableParallel`** takes a mapping of names to runnables. On `invoke(x)` it runs *every* value on `x` — concurrently, using a thread pool in the sync path and `asyncio.gather` in the async path — and returns a dict of the same keys with each branch's output. The crucial property is **replacement**: the output dict contains exactly the mapping's keys. Any field of the input that no branch reproduces is dropped. **`RunnablePassthrough.assign(**kwargs)`** is the merging variant. It expects a dict input, runs each keyword's runnable on that whole dict, and returns the input dict updated with the new keys. Existing keys survive; a name collision overwrites. This is the additive, accumulating idiom. ## Choosing between them Ask what the next step needs: - *The next step needs a brand-new shape built from the input* → parallel dict. You are projecting, and you want the old shape gone so the contract is explicit. - *The next step needs the original fields plus something computed* → `assign`. You are enriching, and re-listing every carried field in a parallel mapping would be noise that silently drops a field the day someone adds one. A chain of three enrichment steps written with parallel dicts has to restate every surviving key three times; written with `assign` it reads as three additions. Conversely, using `assign` where you meant to project leaves dead fields travelling down the chain, which bloats anything that serialises the payload and makes traces harder to read. ## Concurrency and cost Branches in a parallel block are genuinely concurrent, which is the performance argument for using one: two independent network calls take max(t1, t2) rather than t1 + t2. But concurrency is per-block, not global — nesting parallels multiplies in-flight work, and each branch inherits the same `RunnableConfig`, so a `max_concurrency` you set for a batch does not separately cap fan-out width inside one invocation. If a branch is expensive and its result is only sometimes needed, the parallel block will still pay for it every time; there is no laziness. ## Error semantics If any branch raises, the whole parallel step raises — there is no partial dict. If you want a degradable branch, decorate that branch alone with `.with_fallbacks([...])` so it produces a default instead of failing the block. Similarly, a slow branch sets the floor for the whole step; a per-branch `.with_config()` carrying a shorter timeout (where the underlying component honours one) is the lever. ## Shape discipline Both constructs are where shape bugs breed, because the pipe operator does not typecheck at build time. Two habits help: 1. Keep the key names identical to what the downstream step expects, so the mapping reads as the downstream contract. 2. Use `itemgetter` from the standard library as a cheap projecting step — it is a callable, so LCEL coerces it into a `RunnableLambda` automatically. ## Streaming caveat A parallel block cannot stream a dict incrementally the way a single string-producing step streams chunks: downstream steps generally see the block's result once all branches finish. If token-level streaming to the caller matters, keep the fan-out early in the chain and make the final step the streaming one. ## Common mistakes - Assuming `assign` runs its runnables on a single field rather than on the whole input dict. - Using a parallel dict mid-chain and being surprised that a field set two steps earlier has vanished. - Putting `RunnablePassthrough()` as a top-level step and expecting it to do something — outside a mapping it is a no-op. - Expecting a failed branch to yield `None` rather than propagating the exception.

  • What input does RunnablePassthrough.assign pass to each of its runnables?
    The entire input dict, not just the value under the key being assigned. So `RunnablePassthrough.assign(summary=summarizer)` invokes `summarizer` with the whole dict, and the summarizer is responsible for picking the field it needs — commonly via an `itemgetter` step in front of it. The result is written back under `summary` and merged with the original keys.
  • One branch of a RunnableParallel calls a flaky external service. How do you keep the block usable?
    Decorate that branch alone rather than the block: `flaky.with_retry(stop_after_attempt=3)` for transient errors and `.with_fallbacks([default_runnable])` so a persistent failure yields a usable default instead of raising. A parallel step is all-or-nothing — any unhandled branch exception fails the whole step and no partial dict is returned.
  • Does nesting parallel blocks multiply concurrency, and does that matter?
    Yes. Each block fans out independently, so a parallel with three branches whose branches each fan out to three gives up to nine in-flight operations. There is no global width limit at invoke scope, so against a rate-limited provider you should flatten the structure or throttle at the client, rather than assuming the framework caps it for you.

saying these in an interview costs you the question

  • Thinking assign receives only its own key's value
  • Expecting parallel to preserve unlisted input keys
  • Believing a failed branch returns None
  • Treating RunnablePassthrough as a no-op worth deleting anywhere
  • Assuming branches run sequentially in dict order

context