teachyou.ai academy
← All posts
LangChainLCELPythonLLM orchestrationRAG

A Practical Guide to LangChain Expression Language (LCEL)

Pramod Dutta · Jul 1, 2026 · 13 min read

LangChain LCEL (LangChain Expression Language) is the declarative syntax LangChain uses to compose chains out of small, reusable pieces using the pipe operator |. If you have written prompt | model | parser and wondered what is actually happening under the hood, this guide walks through the mechanics: the Runnable protocol, how streaming and batching propagate automatically, how to branch and merge chains, and where LCEL breaks down so you know when to reach for LangGraph instead.

Most LangChain tutorials show you the pipe syntax without explaining why it exists. That is a mistake, because LCEL is not just sugar. It is a protocol. Once you understand the protocol, you can build your own composable pieces, debug chains that misbehave, and make an informed call about when a plain Python function is actually simpler than a chain.

What problem LangChain LCEL actually solves

Before LCEL, a typical LangChain chain was a Python class instance: you built a LLMChain, called .run() or .predict() on it, and if you wanted streaming, batching, or async support, you had to check whether that specific chain class implemented it. Every chain type had its own quirks. Composing two chains meant writing glue code that manually passed outputs from one into inputs of another, and none of that glue got you streaming or async for free.

LCEL fixes this by defining one interface, Runnable, that every composable piece implements: prompts, chat models, output parsers, retrievers, and your own custom logic. Because everything shares the same interface, you can chain any of them together with |, and the resulting pipeline automatically inherits:

  • .invoke() for a single synchronous call
  • .batch() for running many inputs concurrently
  • .stream() for token-by-token output
  • .ainvoke(), .abatch(), .astream() for the async equivalents

You write the pipeline once. Streaming, batching, and async are not things you implement per chain, they are things you get because every piece in the pipeline speaks the same protocol.

The Runnable protocol

A Runnable is any object with invoke, batch, stream, and their async counterparts, plus a defined input and output schema. LangChain gives you several built-in Runnable types, and the pipe operator is really operator overloading on __or__ that wraps two Runnables into a RunnableSequence.

Here is the minimal chain most people start with:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_anthropic import ChatAnthropic

prompt = ChatPromptTemplate.from_template(
    "Explain {topic} in two sentences for a {audience}."
)
model = ChatAnthropic(model="claude-sonnet-4-5")
parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke({"topic": "LCEL", "audience": "junior developer"})
print(result)

Three completely different object types, ChatPromptTemplate, ChatAnthropic, and StrOutputParser, all implement Runnable, so they compose with | regardless of what they do internally. prompt takes a dict and returns a formatted prompt value. model takes a prompt value and returns a message. parser takes a message and returns a string. Each step's output type is the next step's expected input type, and LCEL does not check this for you at chain-build time, so type mismatches surface at runtime.

Streaming for free

Because every piece in the chain implements .stream(), and RunnableSequence knows how to propagate a stream through each step, you get token streaming without touching the code above:

for chunk in chain.stream({"topic": "LCEL", "audience": "junior developer"}):
    print(chunk, end="", flush=True)

This works because StrOutputParser streams partial string chunks as the model emits tokens, rather than waiting for the full message before parsing. If you swap the last step for something that cannot produce partial output incrementally, like a step that needs the full response before it can do anything (say, a JSON parser wrapping a whole object), streaming degrades gracefully: LangChain buffers internally and yields the final result as one chunk instead of failing.

Batching for free

Swap .invoke() for .batch() and pass a list of inputs:

inputs = [
    {"topic": "vector search", "audience": "product manager"},
    {"topic": "prompt caching", "audience": "backend engineer"},
    {"topic": "agentic RAG", "audience": "student"},
]

results = chain.batch(inputs, config={"max_concurrency": 5})

max_concurrency caps how many requests run in parallel, which matters when you are calling a rate-limited API. Under the hood, .batch() uses a thread pool for sync Runnables and asyncio.gather for the async version, .abatch(). If one of the runnables in your chain does not have a genuinely parallel implementation, .batch() still works, it just falls back to running invoke calls in a loop, so batching a slow custom function will not magically make it fast. It is a convenience API, not a performance guarantee for arbitrary code.

RunnableParallel: running steps side by side

A single pipe chain is a straight line. Real applications often need to run several things off the same input and merge the results, for example fetching context from a retriever while also formatting the raw question for a different prompt slot. RunnableParallel (often written as a plain dict inside a chain) does this:

from langchain_core.runnables import RunnableParallel, RunnablePassthrough

retrieval_chain = RunnableParallel(
    context=retriever,
    question=RunnablePassthrough(),
)

rag_chain = retrieval_chain | prompt | model | parser

RunnablePassthrough() just forwards the input unchanged, which is the standard trick for keeping the original question available to a later prompt template while a sibling branch does retrieval. When retrieval_chain.invoke("what is LCEL?") runs, both context and question execute concurrently (if they support async or threading) and the results land in a dict: {"context": [...], "question": "what is LCEL?"}. That dict becomes the input to the next prompt step, which expects {context} and {question} template variables.

This is the backbone of almost every LCEL-based RAG chain you will see in the LangChain documentation. If you have copied a RAG example and were confused by the dict-shaped step before the prompt, this is what it is doing: fan out to a retriever and a passthrough, fan back in as a dict.

RunnableLambda: wrapping your own functions

Not everything is a prompt, model, or parser. RunnableLambda lets you drop plain Python functions into a chain so they participate in the same protocol:

from langchain_core.runnables import RunnableLambda

def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)

rag_chain = (
    RunnableParallel(
        context=retriever | RunnableLambda(format_docs),
        question=RunnablePassthrough(),
    )
    | prompt
    | model
    | parser
)

Here format_docs takes the list of Document objects a retriever returns and joins them into one string before the prompt template gets them, since a prompt template variable expects a string, not a list of objects. RunnableLambda is the escape hatch for "I need one line of glue logic here," and you will use it constantly, most commonly for reshaping data between two steps whose types do not naturally line up.

You rarely need to write RunnableLambda(fn) explicitly, by the way, LangChain auto-coerces a plain function into a Runnable when it appears directly inside a | chain or a dict passed to RunnableParallel. Wrapping it explicitly is mostly useful when you want to name the step, attach config, or reuse the function object elsewhere.

RunnableBranch and conditional routing

Sometimes the next step depends on the input, for example routing a support ticket to a "billing" prompt or a "technical" prompt based on classification. RunnableBranch implements if/elif/else at the Runnable level:

from langchain_core.runnables import RunnableBranch

branch = RunnableBranch(
    (lambda x: x["topic"] == "billing", billing_chain),
    (lambda x: x["topic"] == "technical", technical_chain),
    general_chain,
)

full_chain = classify_chain | branch

Each tuple is (condition, runnable), checked in order, and the last positional argument (no tuple) is the default. classify_chain would typically be an earlier chain step that produces {"topic": "billing", ...} from the raw input, which branch then reads to pick a path.

For anything beyond simple conditionals, especially routing that depends on model output that needs validation, retries, or loops, RunnableBranch becomes awkward. That is a signal you have outgrown LCEL for that part of your app, more on this below.

Configurable chains: swapping models and params at call time

LCEL Runnables support .with_config(), .bind(), and configurable_fields / configurable_alternatives, which let you change model, temperature, or other parameters per invocation without rebuilding the chain:

model = ChatAnthropic(model="claude-sonnet-4-5", temperature=0).configurable_fields(
    temperature=ConfigurableField(
        id="temperature",
        name="Model Temperature",
    )
)

chain = prompt | model | parser

# override temperature just for this call
chain.invoke(
    {"topic": "LCEL", "audience": "junior developer"},
    config={"configurable": {"temperature": 0.9}},
)

.bind() is the simpler cousin: it partially applies fixed keyword arguments to a Runnable, useful for things like binding stop sequences or tool schemas to a model without touching the chain definition:

model_with_tools = model.bind(tools=[my_tool_schema])
chain = prompt | model_with_tools | parser

This matters in production because it means one chain object can serve requests with different runtime parameters (say, a per-tenant model choice) instead of constructing a new chain per request.

Fallbacks and retries

.with_fallbacks() and .with_retry() are the two reliability primitives every production LCEL chain should use somewhere. Fallbacks let you specify a backup Runnable if the primary one raises:

primary = ChatAnthropic(model="claude-opus-4-5")
backup = ChatAnthropic(model="claude-haiku-4-5")

model_with_fallback = primary.with_fallbacks([backup])

chain = prompt | model_with_fallback | parser

If the primary model call raises an exception (rate limit, timeout, provider outage), LangChain automatically retries the same input against backup before propagating the error further up.

.with_retry() retries the same Runnable on failure with exponential backoff, which is the right tool for transient errors (a dropped connection) as opposed to .with_fallbacks(), which is for switching to a different implementation entirely:

resilient_model = model.with_retry(
    stop_after_attempt=3,
    wait_exponential_jitter=True,
)

You can stack both: retry the primary a couple of times, then fall back to a cheaper or different model if retries are exhausted.

Inspecting a chain: get_graph and input/output schemas

Because every Runnable declares typed input and output schemas, you can introspect a chain before running it, which is useful when a chain built by someone else on your team throws a confusing error:

print(chain.input_schema.model_json_schema())
print(chain.output_schema.model_json_schema())
chain.get_graph().print_ascii()

get_graph().print_ascii() prints a text diagram of the chain's steps in order, which is the fastest way to understand a chain you did not write, especially one with nested RunnableParallel branches that are hard to read from source alone.

Where LCEL stops being the right tool

LCEL is built for directed, mostly linear pipelines: format input, call a model, parse output, maybe branch once or fan out and back in. It is not built for:

  • Cycles. An agent loop that calls a tool, feeds the result back to the model, and repeats until the model decides to stop is a graph with a cycle, not a straight line. You can hack this with recursive Python calls inside a RunnableLambda, but at that point you have reinvented a worse version of a state machine.
  • Complex state that persists across steps. LCEL chains pass a single value (often a dict) down the pipe. Once you need to track multiple pieces of evolving state, conditionally skip steps based on accumulated history, or checkpoint progress so a long-running job can resume after a crash, you want an explicit state object, not a dict getting reshaped at every pipe.
  • Human-in-the-loop interruption. Pausing a chain mid-execution to wait for human approval, then resuming exactly where it left off, is not something LCEL's synchronous pipe model supports natively.

This is exactly the gap LangGraph fills: it models your application as a graph of nodes and edges with explicit state, supports cycles, and has built-in persistence for pausing and resuming. The LangChain team's own guidance is direct about this: use LCEL for chains (a fixed sequence of steps), use LangGraph for agents (dynamic, possibly cyclic control flow). If you find yourself writing a while loop around .invoke() calls or nesting RunnableBranch more than one level deep to fake a loop, that is the signal to stop and move that part of the system to LangGraph.

A practical rule that holds up in most codebases: if you can draw the chain on a whiteboard as a straight line or a simple tree with one merge point, LCEL is the right level of abstraction. If your whiteboard drawing has an arrow going backwards, reach for LangGraph.

A complete example: RAG with streaming, fallback, and source citations

Putting the pieces together, here is a chain that retrieves documents, formats them with inline citations, streams the answer, and falls back to a cheaper model if the primary one fails:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel, RunnablePassthrough, RunnableLambda
from langchain_anthropic import ChatAnthropic

def format_docs_with_sources(docs):
    return "\n\n".join(
        f"[{i+1}] {d.page_content}\nSource: {d.metadata.get('source', 'unknown')}"
        for i, d in enumerate(docs)
    )

RAG_PROMPT = ChatPromptTemplate.from_template(
    "Answer the question using only the context below. "
    "Cite sources using [1], [2] notation.\n\n"
    "Context:\n{context}\n\nQuestion: {question}"
)

primary_model = ChatAnthropic(model="claude-sonnet-4-5")
backup_model = ChatAnthropic(model="claude-haiku-4-5")
model = primary_model.with_fallbacks([backup_model])

rag_chain = (
    RunnableParallel(
        context=retriever | RunnableLambda(format_docs_with_sources),
        question=RunnablePassthrough(),
    )
    | RAG_PROMPT
    | model
    | StrOutputParser()
)

for chunk in rag_chain.stream("How does LCEL propagate streaming through a chain?"):
    print(chunk, end="", flush=True)

Every capability here, streaming, fallback, the parallel retrieval branch, comes from composing small Runnables rather than writing a bespoke class. That composability is the actual value of LCEL: it is not about the pipe syntax looking clean, it is about every piece you plug in inheriting streaming, batching, async, retries, and fallbacks without you writing that plumbing yourself.

FAQ

What is the difference between LCEL and a regular Python function chain? A regular function chain, c = f(g(h(x))), only does what you wrote. An LCEL chain built from Runnable objects automatically gets .batch(), .stream(), async variants, retries, and fallbacks because every piece implements the same protocol. You lose a bit of Python readability in exchange for that infrastructure being handled for you.

Do I need LCEL to use LangChain at all? No. You can call a chat model directly, or use LangGraph without ever writing a pipe chain. LCEL is the right choice specifically when you have a linear or lightly-branching pipeline and want streaming, batching, and retries without hand-rolling them.

Why does my chain throw a validation error I don't understand? Almost always a schema mismatch between two adjacent steps, for example a prompt template expecting {context} as a string but receiving a list of Document objects. Run chain.get_graph().print_ascii() and check input_schema / output_schema on the two steps around the failure point to see the mismatch directly instead of guessing from the stack trace.

Can I mix LCEL chains inside a LangGraph node? Yes, and it is a common and recommended pattern. Use LangGraph for the overall control flow (loops, state, human-in-the-loop), and use an LCEL chain inside individual nodes for the linear "format prompt, call model, parse output" work each node does.

Does LCEL support async natively, or do I need to rewrite my chain? Native. Every built-in Runnable implements ainvoke, abatch, and astream alongside their sync counterparts, and RunnableLambda will wrap an async def function correctly if you pass one. You do not rewrite the chain structure to go async, you just call the a-prefixed methods.

Is `RunnableParallel` actually parallel, or does it just look that way? It runs genuinely concurrently when the underlying Runnables support async or thread-based execution, which all built-in LangChain components do. For custom RunnableLambda functions wrapping blocking I/O, wrap the call in a thread or provide an async implementation, otherwise LangChain runs it in a thread pool by default for sync functions inside .batch() and parallel branches, so it is still non-blocking relative to the rest of the chain in most practical cases.

When should I stop using LCEL and switch to LangGraph? When your control flow needs a cycle (agent tool loops), persistent state beyond what fits cleanly in a dict passed step to step, or the ability to pause and resume execution (human approval, long-running jobs). If you are nesting RunnableBranch to simulate a loop, that is the tell.