What does the | operator build in LangChain's LCEL, and what gets coerced?
answer
- Pipe builds, it does not run
- RunnableSequence is itself a Runnable
- Dict becomes parallel, function becomes lambda
- __or__ and __ror__ do the wiring
- Config propagates down every nested step
basics
~20 sThe pipe operator builds a RunnableSequence — itself a Runnable — that feeds each step's output into the next. Along the way LCEL coerces plain values: a dict becomes a RunnableParallel and a callable becomes a RunnableLambda.
solid answer
~40 s`a | b | c` does not execute anything; it constructs a `RunnableSequence` whose steps run left to right, each receiving the previous step's output. Because the sequence is itself a `Runnable`, it exposes `invoke`, `batch`, `stream` and their async twins, and it propagates the `RunnableConfig` — callbacks, tags, metadata — into every nested step, which is what makes end-to-end tracing work. LCEL also coerces non-Runnable operands so the syntax stays light: a **dict** on either side becomes a `RunnableParallel` running each value on the same input, and a **plain callable** becomes a `RunnableLambda`. That coercion is why `{"x": retriever, "y": RunnablePassthrough()} | prompt` works with no wrapper classes. The mechanics are Python's `__or__`/`__ror__` on `Runnable`, so at least one operand must already be a Runnable for the expression to build.
code
python · 9 linesfrom langchain_core.runnables import RunnableLambda, RunnablePassthrough
double = RunnableLambda(lambda x: x * 2)
# dict is coerced to RunnableParallel, plain function to RunnableLambda
chain = {"n": RunnablePassthrough()} | RunnableLambda(lambda d: d["n"]) | double | (lambda x: f"result={x}")
print(chain.invoke(5)) # result=20
print(chain.batch([1, 2, 3])) # ['result=4', 'result=8', 'result=12']go deeper
Be able to read a piped chain aloud: each step's output feeds the next, and nothing runs until you call invoke. Know that the whole chain is itself a Runnable.
Explain the two coercions — dict to RunnableParallel, callable to RunnableLambda — and that the pipe only constructs a RunnableSequence, so shape errors surface at invoke time, not at build time.
Show why the sequence matters operationally: config and callbacks propagate to every nested step, decorators like with_retry apply to the whole chain, and named steps are what make a trace debuggable in production.
Own the judgment call about how much declarative plumbing a codebase should adopt: uniform streaming, async and tracing are the return, indirection in stack traces and a legacy-era knowledge tax on new hires are the cost.
## The pipe is a constructor, not a call The most common misreading of LCEL is that `|` runs something. It does not. `a | b` invokes `Runnable.__or__`, which returns a `RunnableSequence` holding the two steps. Nothing touches a model until you call `invoke`, `stream`, or `batch` on the result. Chains in LCEL are therefore *declarative values*: you can build them at import time, hold them in a module-level variable, and reuse them across requests, because they carry no per-call state. Writing `a | b | c` produces a single flattened sequence rather than nested pairs, and the sequence runs its steps strictly left to right, threading each step's output into the next as that step's input. If step two expects a dict and step one produced a string, you get a runtime error — LCEL does no structural adaptation for you beyond the coercion rules below. ## Coercion rules Because writing `RunnableLambda(...)` around every small function would drown the syntax, LCEL coerces two kinds of operand when they appear next to a Runnable in a pipe (or as a value inside a parallel): - **A `dict` becomes a `RunnableParallel`.** `{"context": retriever, "question": RunnablePassthrough()}` runs both values on the *same* input, concurrently, and produces a dict with the same keys holding their outputs. This is the standard way to assemble a multi-field input for a downstream step. - **A callable becomes a `RunnableLambda`.** Any plain function, lambda, or `itemgetter` slotted into the chain is wrapped, so `chain | (lambda x: x.content.upper())` is valid. The `@chain` decorator in `langchain_core.runnables` does the same thing explicitly and is worth using for named functions, because it gives the step a proper name in traces. What is *not* coerced: strings, ints, and arbitrary objects. `chain | "hello"` is an error, not a constant step. ## Why `__ror__` matters Python dispatches `x | y` to `type(x).__or__` first and falls back to `type(y).__ror__`. `Runnable` implements both, which is why a dict or a function can appear on the *left* of the pipe — `{"q": RunnablePassthrough()} | prompt | model` builds fine even though a dict has no `__or__` for Runnables. The rule to remember is that at least one operand in each `|` must already be a Runnable; a pipe between two plain dicts is just Python's dict union. ## What the sequence buys you Three things, all of which you would otherwise hand-write: 1. **The full calling surface.** The composed chain has `invoke`/`ainvoke`, `batch`/`abatch`, `stream`/`astream`, inherited without effort. Streaming in particular is delegated: the sequence pushes chunks from whichever steps can produce them incrementally. 2. **Config and callback propagation.** A `RunnableConfig` passed at the top — tags, metadata, `run_name`, callbacks, `max_concurrency` — reaches every nested step, so a tracing backend sees one tree of runs instead of disconnected calls. 3. **Uniform decoration.** Because the sequence is a Runnable, `.with_retry()`, `.with_fallbacks()`, `.with_config()` and `.bind()` apply to the whole chain exactly as they apply to one step. ## Version note: this is not the 0.x chain API In LangChain v1, LCEL is the composition primitive in `langchain-core`. The older single-purpose chain classes from the 0.1/0.2 era — the `LLMChain`-style wrappers built by subclassing a base chain and calling `.run()` — are legacy; a candidate who describes them as the current way to compose is signalling stale knowledge. If an interviewer asks about them, say plainly that they predate LCEL and that the modern equivalent is a `RunnableSequence` you compose yourself. ## Failure modes worth naming - **Shape mismatch between steps** is the number-one runtime error, and it is invisible at construction time because the pipe only builds. Inspect with `chain.input_schema` / `chain.output_schema` when debugging. - **An anonymous lambda in the middle** shows up in traces as an unhelpfully generic step. Name it, or use `@chain`, or pass `run_name` via `.with_config()`. - **Confusing sequential with parallel.** A dict is parallel over the *same* input; a pipe is sequential over *transformed* input. Mixing the two up produces a chain that silently feeds the wrong thing forward. - **Expecting cycles.** A sequence is a straight line. There is no way to jump back to an earlier step; that requirement means you have outgrown a plain LCEL sequence.
- Why can a dict appear on the left-hand side of the pipe when dicts know nothing about Runnables?Python falls back to the right operand's `__ror__` when the left operand's `__or__` returns NotImplemented. `Runnable` implements `__ror__`, so `{"q": RunnablePassthrough()} | prompt` is resolved by the prompt, which coerces the dict into a `RunnableParallel`. The rule is that at least one operand of each pipe must already be a Runnable.
- How would you debug a chain that fails because one step passes the wrong shape to the next?Build the chain, then inspect `chain.input_schema` and `chain.output_schema`, or invoke the prefix of the chain in isolation — LCEL sequences slice cleanly because each step is an ordinary Runnable. Adding a named `@chain` function or a `.with_config(run_name=...)` on the suspect step makes the trace readable so you can see exactly what arrived.
- Is there a cost to composing everything with pipes rather than writing plain Python calls?Yes: indirection. Stack traces run through the sequence machinery, an anonymous lambda step is hard to identify in a trace, and simple two-call flows gain nothing from the wrapper. The payoff is the inherited streaming, batching, async and config propagation — if you need none of those, calling the model client directly is legitimately simpler.
saying these in an interview costs you the question
- Thinking a | b executes immediately
- Calling LLMChain the current composition API
- Assuming a dict step runs its values sequentially
- Believing the pipe adapts mismatched shapes between steps
- Expecting loops or cycles inside a RunnableSequence