TechByteByByte

Runnables and LCEL: Why the Pipe Operator Exists

Understand the shared interface behind every LangChain component, why it makes the | pipe operator possible, and how to compose real pipelines with parallel execution, passthroughs, and custom logic.

#LangChain#LCEL#Runnables#Composition

Back in Module 7, Example 7, you did something and we deliberately didn’t give it a name yet:

formatted = prompt.invoke({"topic": "RAG", "level": "beginner", "sentence_count": 2})
response = model.invoke(formatted)

We promised this two-step version was about to “collapse into something genuinely elegant.” This module is where we deliver on that promise properly — not just by showing you the shortcut, but by making sure you understand exactly why it’s possible, which is a genuinely different and more valuable thing to know.

The problem with manual chaining, made obvious

Let’s stay with plain Python for one more moment and see what happens as a pipeline grows past two steps. Suppose you want to format a prompt, get a model’s reply, and then extract just the plain text from that reply — three steps, not two.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

# manually, step by step
prompt_value = prompt.invoke({"topic": "RAG"})
model_output = model.invoke(prompt_value)
parsed = parser.invoke(model_output)

print(parsed)

This works. But notice the pattern repeating in every single line: X.invoke(previous_result). Every step takes the exact previous line’s output and does nothing with it except hand it straight into the next call. There’s no branching, no decision-making — just a straight, mechanical hand-off, three times in a row, each one written out by hand.

Now imagine a real pipeline with five or six steps instead of three. You’d be writing this same repetitive X.invoke(previous_result) pattern over and over, and a reader skimming your code would have to trace each variable name carefully just to understand that this is, in fact, one single, linear pipeline — not five separate, unrelated things.

The actual insight: every one of these things shares the same interface

Look again at that manual version. prompt.invoke(...). model.invoke(...). parser.invoke(...). Three genuinely different kinds of objects — a prompt template, a chat model, an output parser — and every single one of them is called the exact same way: .invoke(some_input), returning some output.

This is not a coincidence, and it’s the single most important idea in this entire module. In LangChain, a Runnable is the name for any component that implements this same shared interface: .invoke(), plus a small family of related methods you’re about to meet properly. Prompts are Runnables. Chat models are Runnables. Output parsers are Runnables. So are retrievers, and so are the agents you’ll build starting in a few modules. Everything.

flowchart LR
    A["ChatPromptTemplate"] -->|implements| R["Runnable interface\n.invoke() .batch() .stream() .ainvoke()"]
    B["Chat Model"] -->|implements| R
    C["Output Parser"] -->|implements| R
    D["Retriever"] -->|implements| R

Because every one of these components genuinely honors the same interface, LangChain can offer one single, consistent piece of syntax for connecting any of them together, in any order, without needing to know or care what specific kind of component sits on either side of the connection. That syntax is the | operator, and it’s the entire reason it’s able to exist at all.

Example 1: the pipe operator, doing exactly what you did by hand

Let’s rewrite the three-step example above, using | instead of manual chaining.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke({"topic": "RAG"})
print(result)

Read that chain = prompt | model | parser line the way you’d read the manual version: “take the prompt’s output, feed it into the model; take the model’s output, feed it into the parser.” The | operator is doing precisely, mechanically, what those three manual lines did — nothing more mysterious than that. What’s genuinely new here is chain itself: it’s a real, single Runnable of its own, built by combining three smaller ones, and it exposes exactly the same .invoke() interface as every one of its individual pieces did.

Let’s also introduce StrOutputParser properly, since we used it without explanation: its one job is taking an AIMessage — the kind of object you learned about back in Module 4 and Module 6 — and pulling out just the plain .content string, discarding the rest. It’s a small, simple Runnable, but a genuinely useful one, since you often just want the text, not the full message object.

Example 2: the same chain, with Gemini instead

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model = init_chat_model("google_genai:gemini-2.0-flash")
parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke({"topic": "RAG"})
print(result)

Nothing about the chain-building syntax changed at all — only the model swapped, exactly the portability you first saw back in Module 1, now working identically for an entire multi-step chain, not just a single model call.

Example 3: .batch() — running many inputs through the whole chain at once

Because chain is a genuine Runnable, it doesn’t just inherit .invoke() — it inherits the entire shared interface, including methods you haven’t used on a multi-step chain before.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

chain = prompt | model | parser

topics = [{"topic": "RAG"}, {"topic": "embeddings"}, {"topic": "transformers"}]
results = chain.batch(topics)

for topic, result in zip(topics, results):
    print(topic["topic"], "→", result)

.batch() runs the entire three-step chain — prompt formatting, model call, parsing — across every item in the list, and importantly, it doesn’t just loop through them one at a time behind the scenes. LangChain sends these as concurrent requests where the provider supports it, meaning three topics can genuinely be processed faster than calling .invoke() three separate times in a row.

Example 4: .stream() on the whole chain

Recall streaming a single model’s reply back in Module 4. Now watch it work across an entire chain.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Write a short poem about {topic}.")
model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

chain = prompt | model | parser

for chunk in chain.stream({"topic": "the ocean at night"}):
    print(chunk, end="", flush=True)

Even though parser sits after model in the chain, streaming still works correctly, piece by piece, all the way through. StrOutputParser is specifically built to handle streamed chunks as they arrive, rather than needing to wait for the entire reply to finish first. This is genuinely important: composing Runnables together doesn’t break their individual capabilities — the whole chain remains just as streamable as the model alone was.

Example 5: .ainvoke() — the async version of the whole chain

The same pattern extends to async, exactly as you’d now expect.

import asyncio
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

chain = prompt | model | parser

async def main():
    result = await chain.ainvoke({"topic": "vector databases"})
    print(result)

asyncio.run(main())

invoke, batch, stream, ainvoke, abatch, astream — this full family of methods is what the Runnable interface actually guarantees, and you now have real, working proof that a chain you built yourself, out of three separate pieces, honors every single one of them, automatically, with zero extra code from you.

Example 6: RunnableParallel — running several things on the same input at once

So far, every chain has been a straight line: one step feeds the next. Real applications often need to run several different things on the same input simultaneously — say, summarizing a piece of text and separately extracting its sentiment, both from the same original input.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel

model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

summarize = ChatPromptTemplate.from_template("Summarize in one sentence: {text}") | model | parser
sentiment = ChatPromptTemplate.from_template("What's the sentiment (one word) of: {text}") | model | parser

combined = RunnableParallel(summary=summarize, sentiment=sentiment)

result = combined.invoke({"text": "The new update finally fixed the bug that had been frustrating users for months."})
print(result)

RunnableParallel takes the same input and runs it through each of its named branches — summary and sentiment — genuinely concurrently, then returns a dictionary combining both results, keyed by the names you chose. This is a real, meaningful speed advantage over running the two prompts one after another, since neither task depends on the other’s result.

Example 7: RunnablePassthrough — keeping the original input alongside new results

Sometimes you need the original input to survive alongside whatever a chain produces from it — for instance, keeping the original question visible next to its answer.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel, RunnablePassthrough

model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

answer_chain = ChatPromptTemplate.from_template("Answer briefly: {question}") | model | parser

combined = RunnableParallel(
    original_question=RunnablePassthrough(),
    answer=answer_chain,
)

result = combined.invoke({"question": "Why is the sky blue?"})
print(result)

RunnablePassthrough() does something genuinely simple, but easy to underestimate: it just hands its input straight through, completely unchanged, as its output. Paired with RunnableParallel, it lets you preserve the original input right alongside whatever transformation you ran on it — you’ll see this exact pattern again, and it’ll make much more sense, once we build real RAG pipelines later in this course, where you often need both the retrieved context and the original question available together.

Example 8: RunnableLambda — dropping ordinary Python into the chain

Not everything in a real pipeline needs to be a prompt or a model call. Sometimes you just need a small, ordinary Python transformation, sitting between two other steps.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda

def add_greeting(text: str) -> str:
    return f"Here's your answer: {text}"

model = init_chat_model("openai:gpt-4o-mini")
prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
parser = StrOutputParser()

chain = prompt | model | parser | RunnableLambda(add_greeting)

result = chain.invoke({"topic": "gradient descent"})
print(result)

RunnableLambda wraps an ordinary Python function — no special decoration needed beyond the wrapper itself — and turns it into a genuine Runnable, so it can sit inside a | chain exactly like any prompt or model. This is a genuinely important escape hatch: you’re never limited to only LangChain’s own built-in components. Any plain Python logic your application needs can become a real step in the pipeline.

Example 9: RunnableBranch — routing to different logic based on the input

Sometimes a chain needs to make a genuine decision about which path to take, not just process everything the same way.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch

model = init_chat_model("openai:gpt-4o-mini")
parser = StrOutputParser()

simple_chain = ChatPromptTemplate.from_template("Give a one-sentence answer: {question}") | model | parser
detailed_chain = ChatPromptTemplate.from_template("Give a thorough, detailed answer: {question}") | model | parser

branch = RunnableBranch(
    (lambda x: len(x["question"]) > 60, detailed_chain),  # long questions get detailed answers
    simple_chain,  # everything else falls through to this default
)

print(branch.invoke({"question": "What is RAG?"}))
print(branch.invoke({"question": "Can you explain, in real depth, how retrieval-augmented generation actually reduces hallucination in production LLM applications?"}))

RunnableBranch takes a series of (condition, runnable) pairs, checked in order, plus one final default Runnable to fall back on if nothing matches. This is genuinely useful for building real, deliberate logic into a pipeline — routing a short question to a quick answer and a longer, more involved question to a more thorough one — without stepping outside the Runnable world to do it.

Common mistakes worth avoiding

Forgetting that chain.invoke() returns different types depending on the last component. Recall Example 1 — a chain ending in parser (a StrOutputParser) returns a plain string, but a chain ending directly in model, without a parser, returns a full AIMessage. Trying to treat one as the other — calling string methods on an AIMessage, or looking for .content on a plain string — is a genuinely common, easy-to-make mistake the moment you remove or add a parser at the end of a chain.

Reaching for RunnableLambda for something a built-in Runnable already does better. It’s tempting to wrap everything in a custom Python function out of habit. But recall Examples 6 and 7 — RunnableParallel and RunnablePassthrough already handle their specific jobs cleanly, and using them directly keeps your chain’s structure easy to read at a glance, rather than hiding genuinely standard logic inside an opaque custom function.

Assuming every Runnable in a chain streams equally well. Recall Example 4’s honest point about StrOutputParser being specifically built to handle incremental chunks. Not every custom RunnableLambda you write will automatically support streaming gracefully — a function that expects its entire input before it can run (like one that needs a complete sentence to count words) will effectively block streaming at that point in the chain, even if every step around it streams fine.

What you should take away from this module

  • A Runnable is any component that shares the same interface: .invoke(), .batch(), .stream(), and their async twins. Prompts, models, parsers, retrievers, and chains you build yourself are all Runnables.
  • The | operator connects Runnables together, and it’s only possible because every component shares this same interface — it’s mechanically identical to the manual X.invoke(previous_result) chaining you did by hand, just far more readable.
  • A chain built from | is itself a genuine Runnable, which is why it inherits .batch(), .stream(), and .ainvoke() automatically, with no extra code from you.
  • RunnableParallel runs several branches on the same input concurrently. RunnablePassthrough preserves the original input alongside new results. RunnableLambda lets ordinary Python functions become real chain steps. RunnableBranch adds genuine conditional routing.

Where this goes next

The next module puts these Runnables to work building real, multi-stage pipelines — translation chains, summarization chains, classification chains, and a query-generation-then-retrieval chain — while also being honest about a piece of LangChain’s own history: the older Chain classes you might still see in older tutorials, and exactly why current LangChain replaced them with the Runnable composition you just learned.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed