LangChain Expression Language (LCEL) Explained
Why LCEL Exists (And Why You Should Care)
If you've written more than one LangChain application, you've probably hit the same wall: chaining a prompt to a model to a parser looks simple in a tutorial, but the moment you need streaming, retries, parallel calls, or async support, the code turns into a tangle of custom classes and glue functions. LangChain Expression Language, or LCEL, was built specifically to remove that friction.
LCEL is a declarative way to compose LangChain components using the pipe operator (|), the same symbol Unix users have piped commands together with for decades. Instead of writing imperative code that calls one function, takes its output, and manually feeds it to the next function, you describe the *shape* of your pipeline once, and LangChain's runtime handles execution, streaming, batching, and async behavior for you automatically.
This matters because production LLM applications are rarely a single call to a single model. They're pipelines: retrieve context, format a prompt, call a model, parse the output, maybe call a second model, maybe run two branches in parallel and merge the results. LCEL gives you a consistent interface — the Runnable — so every piece of that pipeline, no matter how different internally, behaves the same way from the outside. That consistency is what unlocks features like automatic streaming and parallel execution without you writing extra code for each one.
In this article we'll build up LCEL from first principles: the Runnable interface, the pipe operator, RunnablePassthrough, RunnableParallel, RunnableLambda, and the streaming/batch/async methods that come for free. We'll also talk honestly about where LCEL fits today relative to LangGraph, since that's a question a lot of developers have in 2026.
The Runnable Interface: LCEL's Foundation
Every component you chain together in LCEL — prompts, chat models, output parsers, retrievers, even plain Python functions — implements the same Runnable interface. That interface guarantees a small set of methods:
invoke()— run the component on a single input and get a single outputbatch()— run the component on a list of inputs, in parallel where possiblestream()— run the component and yield output chunks as they become availableainvoke(),abatch(),astream()— async versions of the above
Because every piece of your pipeline speaks this same interface, you can connect them with | and the resulting chain is *itself* a Runnable. That's the trick that makes LCEL compose so cleanly: chains of chains are still chains, and they all support the same five methods without you writing any extra code.
Here's the simplest possible LCEL chain — a prompt piped into a model piped into an output parser:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template(
"Explain {topic} to a beginner in two sentences."
)
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
output_parser = StrOutputParser()
chain = prompt | model | output_parser
result = chain.invoke({"topic": "LCEL"})
print(result)Read that chain line out loud: "prompt, then model, then output parser." That's exactly what happens. The dictionary you pass to invoke() fills in the prompt template, the formatted prompt goes to the model, and the model's raw message output gets converted to a plain string by the parser. No manual wiring, no intermediate variables.
The Pipe Operator: What `|` Actually Does
The | operator isn't Python syntax magic specific to LangChain — it's Python's bitwise OR operator, and LangChain overrides it (via the __or__ dunder method) on every Runnable so that a | b returns a new RunnableSequence object wrapping both steps in order. When you call .invoke() on that sequence, it calls a.invoke() first, then feeds the result into b.invoke(), and returns whatever b produces.
This is worth understanding precisely because it explains why LCEL chains are inspectable and debuggable rather than being an opaque black box. A chain built with | is a real object you can hold, log, and pass around:
chain = prompt | model | output_parser
print(type(chain))
# <class 'langchain_core.runnables.base.RunnableSequence'>
for step in chain.steps:
print(step)Because RunnableSequence is itself a Runnable, you can nest chains inside other chains without any special handling:
summarize_chain = summarize_prompt | model | output_parser
translate_chain = translate_prompt | model | output_parser
full_pipeline = summarize_chain | translate_chainHere, the string output of summarize_chain becomes the input to translate_chain. As long as the output type of one link matches the expected input type of the next, you can keep piping indefinitely. This is the compositional promise of LCEL: small, testable, reusable pieces that snap together like Lego bricks.
RunnablePassthrough: Carrying Data Through the Pipeline
One of the first problems people hit with LCEL is this: your chain needs the original user question at the *end* of the pipeline (say, to combine with retrieved documents), but by the time you're three steps in, the original input has already been transformed and is gone. RunnablePassthrough solves exactly this.
RunnablePassthrough is a Runnable that does nothing to its input — it just passes it through unchanged. On its own that sounds pointless, but combined with RunnableParallel (which we'll cover next), it lets you fork the input so one branch transforms it while another branch preserves it untouched.
The classic use case is Retrieval-Augmented Generation (RAG):
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
rag_prompt = ChatPromptTemplate.from_template(
"""Answer the question using only the context below.
Context:
{context}
Question: {question}
"""
)
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| rag_prompt
| model
| StrOutputParser()
)
answer = rag_chain.invoke("What is LCEL used for?")Notice the dictionary at the start of the chain. LCEL lets you write a plain Python dict inside a chain, and it automatically converts it into a RunnableParallel under the hood. When rag_chain.invoke("What is LCEL used for?") runs, the same input string is sent to both keys simultaneously: the "context" key routes it through the retriever and then format_docs, while "question" uses RunnablePassthrough() to hand the raw string straight through unchanged. Both results get merged into a dictionary, which then fills the two placeholders in rag_prompt.
You can also assign extra keys onto a passthrough using .assign(), which is handy when you want to keep the original payload and add computed fields to it:
chain = RunnablePassthrough.assign(
word_count=lambda x: len(x["question"].split())
)
chain.invoke({"question": "How does LCEL handle streaming?"})
# {'question': 'How does LCEL handle streaming?', 'word_count': 5}RunnableParallel: Fanning Out and Merging Back
RunnableParallel is the explicit form of what that dictionary shorthand does implicitly. It runs multiple Runnables against the *same* input concurrently and returns a dictionary of their outputs. This is one of LCEL's biggest practical wins over hand-written pipeline code: because each branch is independent, LangChain can execute them concurrently (using threads for sync calls or asyncio.gather under the hood for async calls), instead of you writing that concurrency logic yourself.
from langchain_core.runnables import RunnableParallel
summary_chain = summary_prompt | model | StrOutputParser()
sentiment_chain = sentiment_prompt | model | StrOutputParser()
keyword_chain = keyword_prompt | model | StrOutputParser()
analysis_chain = RunnableParallel(
summary=summary_chain,
sentiment=sentiment_chain,
keywords=keyword_chain,
)
result = analysis_chain.invoke({"text": long_article_text})
# {'summary': '...', 'sentiment': '...', 'keywords': '...'}Instead of three sequential round trips to the model — waiting for the summary, then the sentiment, then the keywords — LCEL fires all three requests at once and waits for the slowest one. For anything calling external APIs (which almost every LLM chain does), this is a meaningful latency win with zero extra code from you.
You can also nest RunnableParallel inside a larger sequence, which is exactly what the RAG example above does implicitly. Explicitly, it looks like this:
rag_chain = (
RunnableParallel(
context=retriever | format_docs,
question=RunnablePassthrough(),
)
| rag_prompt
| model
| StrOutputParser()
)Both forms compile to the same execution plan. Use the dict shorthand for brevity in simple cases, and the explicit RunnableParallel when you want the branching to be unmistakable to someone reading the code later.
RunnableLambda: Dropping Into Plain Python
Not everything in a pipeline is a prompt or a model call. Sometimes you need arbitrary Python logic — cleaning a string, reshaping a dictionary, calling a non-LangChain function. RunnableLambda wraps any Python callable so it can participate in an LCEL chain like any other component.
from langchain_core.runnables import RunnableLambda
def extract_first_paragraph(text: str) -> str:
return text.strip().split("\n\n")[0]
def add_disclaimer(text: str) -> str:
return f"{text}\n\n(This is AI-generated content.)"
post_process = RunnableLambda(extract_first_paragraph) | RunnableLambda(add_disclaimer)
chain = prompt | model | StrOutputParser() | post_processIn practice, you rarely need to wrap functions explicitly. If you use | with a plain Python function as one of the operands, LangChain automatically coerces it into a RunnableLambda for you:
chain = prompt | model | StrOutputParser() | extract_first_paragraph | add_disclaimerBoth versions behave identically. The automatic coercion is convenient, but be aware of one subtlety: functions wrapped this way only support invoke()-style synchronous execution unless you also define an async version. If you're building a chain that will run under astream() in production (say, behind a FastAPI endpoint), define your custom functions as async def and let LangChain pick up the async path automatically:
async def add_disclaimer_async(text: str) -> str:
return f"{text}\n\n(This is AI-generated content.)"
chain = prompt | model | StrOutputParser() | add_disclaimer_async
result = await chain.ainvoke({"topic": "RunnableLambda"})Streaming, Batching, and Async — For Free
This is where LCEL earns its keep. Because every component in a chain implements the same Runnable interface, the *entire chain* automatically supports streaming, batching, and async execution — even if you only ever wrote .invoke() in your head while designing it.
Streaming tokens as the model generates them, instead of waiting for the full response:
for chunk in chain.stream({"topic": "LCEL streaming"}):
print(chunk, end="", flush=True)LangChain propagates streaming through the whole pipeline where possible. If your chain is prompt | model | StrOutputParser(), the parser streams partial string chunks as the model emits tokens, rather than buffering the whole response and parsing it at the end. This is a genuinely hard problem to solve by hand — you'd need every layer of your pipeline to understand partial, incremental data — and LCEL gives it to you as a side effect of using the Runnable interface consistently.
Batching multiple inputs concurrently:
topics = [{"topic": "prompts"}, {"topic": "retrievers"}, {"topic": "agents"}]
results = chain.batch(topics)batch() doesn't just loop and call invoke() three times sequentially — it dispatches all three concurrently (respecting a configurable max_concurrency), which matters a lot when each call involves network latency to a model provider.
Async versions for use inside async web frameworks:
import asyncio
async def main():
result = await chain.ainvoke({"topic": "async LCEL"})
print(result)
asyncio.run(main())The practical implication: you design your chain once, and depending on where you deploy it — a synchronous script, a batch job processing a CSV of prompts, or an async FastAPI endpoint streaming tokens to a browser — you call .invoke(), .batch(), or .astream() on the exact same chain object. You don't rewrite the pipeline for each context.
Adding Resilience: Retries, Fallbacks, and Config
Production chains fail — rate limits, transient network errors, a model provider having a bad day. LCEL bakes retry and fallback logic directly into the Runnable interface via .with_retry() and .with_fallbacks(), so you don't need a separate retry library wrapped around your code.
from langchain_openai import ChatOpenAI
primary_model = ChatOpenAI(model="gpt-4o", temperature=0)
backup_model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
resilient_model = primary_model.with_retry(
stop_after_attempt=3
).with_fallbacks([backup_model])
chain = prompt | resilient_model | StrOutputParser()If primary_model throws an exception, LangChain retries up to three times before giving up and falling back to backup_model. Because with_retry() and with_fallbacks() both return a Runnable, they slot into a | chain exactly like anything else — resilience is composed, not bolted on separately.
You can also pass runtime configuration — like a run_name for tracing, tags for filtering in LangSmith, or per-call parameter overrides — using .with_config():
configured_chain = chain.with_config(
{"run_name": "topic-explainer", "tags": ["blog-demo"]}
)This is particularly useful when you're debugging a complex chain in LangSmith and need to tell which branch of a RunnableParallel produced which trace.
A Complete, Realistic Example
Let's put the pieces together into something closer to a real feature: a chain that takes a user question, retrieves relevant context, generates an answer, and also produces a one-line summary of that answer — all using the patterns above.
from langchain_core.runnables import RunnablePassthrough, RunnableParallel, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_retry(stop_after_attempt=2)
answer_prompt = ChatPromptTemplate.from_template(
"""Use the context to answer the question concisely.
Context: {context}
Question: {question}
"""
)
summary_prompt = ChatPromptTemplate.from_template(
"Summarize this answer in exactly one sentence:\n\n{answer}"
)
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs)
def word_count(text: str) -> int:
return len(text.split())
answer_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| answer_prompt
| model
| StrOutputParser()
)
full_chain = (
{"answer": answer_chain}
| RunnableParallel(
answer=lambda x: x["answer"],
summary=(lambda x: {"answer": x["answer"]}) | summary_prompt | model | StrOutputParser(),
length=lambda x: word_count(x["answer"]),
)
)
output = full_chain.invoke("How does LCEL handle async execution?")
print(output["answer"])
print(output["summary"])
print(output["length"])Walk through this once and you can see every concept from this article in one place: RunnablePassthrough preserving the original question, an implicit RunnableParallel fanning the input to a retriever and a pass-through, RunnableLambda-style function coercion for word_count, a nested chain (summary_prompt | model | StrOutputParser()) reused inside a larger one, and .with_retry() for resilience — all connected with nothing but | and dictionaries.
LCEL vs. LangGraph: Which Should You Use?
By 2026, most teams building anything beyond a straight-line or simple-branching pipeline have heard that LangGraph is the recommended tool for "agentic" workflows — loops, conditional routing based on model decisions, human-in-the-loop interrupts, and durable state across long-running tasks. This raises a fair question: is LCEL obsolete?
No — the two solve different problems, and LangGraph is built on top of the same Runnable interface LCEL introduced. The rule of thumb worth internalizing:
- Use LCEL for chains that are fundamentally a directed sequence or fixed fan-out/fan-in of steps: prompt formatting, retrieval, model calls, parsing, post-processing. If you can draw your pipeline as a straight line or a simple tree without cycles, LCEL is the right level of abstraction — it's lightweight, readable, and every node still gets streaming, batching, and async for free.
- Use LangGraph when you need cycles (an agent that keeps calling tools until it decides it's done), conditional branching that depends on runtime state, persistence across steps, or the ability to pause and resume a workflow with human approval in the middle.
In practice, LangGraph nodes are very often *themselves* LCEL chains. You'll define a node's logic as prompt | model | StrOutputParser() and then wire that node into a graph that handles the looping and conditional routing. Learning LCEL well is not wasted effort if you're heading toward LangGraph — it's the vocabulary LangGraph is written in.
Common Pitfalls When Learning LCEL
A few mistakes come up repeatedly with developers new to LCEL, worth calling out directly:
- Forgetting that dict literals become `RunnableParallel`. If you write
{"a": chain_a, "b": chain_b} | next_step, remember that bothchain_aandchain_breceive the *same* input independently — they are not sequential. - Mismatched input/output types between links. If
chain_aoutputs a string but the next step expects a dictionary with a specific key, you'll get a runtime error, often a confusing one. Print or log intermediate outputs with.invoke()on partial chains while debugging. - Wrapping everything in `RunnableLambda` unnecessarily. Plain functions are auto-coerced when used with
|. Only wrap explicitly when you need to call methods like.with_config()on the function itself. - Ignoring async paths in production code. If your app is async (FastAPI, for instance) but your custom
RunnableLambdafunctions are synchronous, you lose some of the concurrency benefits. Define async twins of your custom functions where performance matters. - Over-engineering simple scripts. If you have a single prompt-to-model call with no branching, you don't need
RunnableParallelor.with_fallbacks()— plain LCEL with|is already doing its job. Reach for the advanced pieces when the pipeline's shape actually requires them.
Wrapping Up
LCEL turns LLM pipelines from imperative glue code into declarative, composable graphs of Runnable objects. The pipe operator (|) is the syntax, but the real value is underneath it: a consistent interface that gives every chain — no matter how it's built — streaming, batching, async execution, retries, and fallbacks without extra implementation work. RunnablePassthrough keeps your original input alive through transformations, RunnableParallel fans work out and merges it back concurrently, and RunnableLambda lets plain Python join the pipeline without ceremony.
Once these pieces click, you'll find yourself reaching for LCEL by default for anything that isn't fundamentally cyclical, and reaching for LangGraph — built on the same Runnable foundations — when your workflow needs loops, state, and conditional agent behavior.
If you want to go deeper than a single article can take you — building multi-step RAG pipelines, wiring in tool-calling agents, debugging chains in LangSmith, and eventually graduating to LangGraph for production agent systems — our LangChain Tutorial 2026 course on teachyou.ai walks through all of it hands-on, with real projects instead of toy examples.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.