n4nAI

LCEL pipe syntax explained with real examples

LCEL pipe syntax explained: how LangChain's | operator composes runnables into chains, with working Python examples, debugging tips, and fixes for common mistakes.

n4n Team3 min read747 words

Audio narration

Coming soon — every post will get a voice note here.

LCEL pipe syntax explained: the | operator in LangChain Expression Language (LCEL) is an overload that chains Runnable objects into a sequence, passing the output of one step as the input to the next. It is not merely function composition; it is a declarative way to build execution graphs that retain streaming, batch, and async behavior end to end.

What the pipe operator actually binds

LangChain core defines a Runnable protocol. Anything implementing invoke, batch, stream, and ainvoke can sit on either side of |. The expression a | b desugars to RunnableSequence(a, b). If you pipe three items, it nests sequences left-associative.

from langchain_core.runnables import RunnableLambda

def add_one(x: int) -> int:
    return x + 1

def mul_two(x: int) -> int:
    return x * 2

chain = RunnableLambda(add_one) | RunnableLambda(mul_two)
print(chain.invoke(10))  # 22

The left runnable’s output type must be compatible with the right runnable’s input type. The pipe does not infer or convert shapes; it forwards whatever object you returned. A common early error is returning a dict from a prompt template and piping into a model that expects a list[BaseMessage]. LCEL will raise at runtime, not at parse time.

How composition preserves execution contracts

A plain def composition loses streaming. If add_one yielded tokens, a manual mul_two(add_one(x)) call blocks until completion. RunnableSequence instead implements stream by calling the left’s stream, consuming its chunks, and feeding them incrementally to the right’s stream when the right supports it.

from langchain_core.runnables import RunnablePassthrough

passthrough = RunnablePassthrough()
model = ...  # ChatOpenAI instance
stream_chain = passthrough | model

for chunk in stream_chain.stream("Say hello slowly"):
    print(chunk.content, end="")

Async works the same way via ainvoke and astream. This is why LCEL pipe syntax explained matters for production: you get concurrency and backpressure for free if the underlying runnables implement them.

Why it matters beyond toy scripts

In a real service, you need retries, fallbacks, and metering. LCEL chains compose with RunnableWithFallbacks and RunnableRetry without changing the pipe shape. Because the pipe is just a runnable, you can wrap the whole sequence:

from langchain_core.runnables import RunnableWithFallbacks

primary = prompt | model
fallback = prompt | cheaper_model
chain = primary.with_fallbacks([fallback])

The same chain.invoke() call now degrades gracefully. When you meter token usage, each model runnable emits a LLMResult with token counts; the sequence passes it upward.

A concrete example: retrieval-augmented Q&A

Below is a minimal RAG chain using pipe syntax. It fans out to fetch context and pass the question, then formats a prompt, calls the model, and parses output.

from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

retriever = ...  # assumes a vectorstore retriever

prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer using context: {context}"),
    ("user", "{question}")
])

model = ChatOpenAI(model="gpt-4o-mini")

chain = (
    RunnableParallel({
        "context": retriever,
        "question": RunnablePassthrough()
    })
    | prompt
    | model
    | StrOutputParser()
)

response = chain.invoke("What is LCEL?")

RunnableParallel takes a dict and runs each value concurrently, collecting results into a dict. The pipe then feeds that dict to prompt, which expects those keys. This is the canonical pattern for branching inside a linear pipe.

Pointing the model at an OpenAI-compatible gateway

The ChatOpenAI binding is just an HTTP client. You can repoint it at any OpenAI-compatible endpoint. For example, n4n.ai exposes one endpoint that fronts 240+ models and applies automatic fallback when a provider is rate-limited or degraded. The LCEL pipe above stays identical; only the client config changes.

model = ChatOpenAI(
    model="gpt-4o-mini",
    base_url="https://api.n4n.ai/v1",
    api_key="YOUR_KEY",
)

Because the gateway honors client routing directives and forwards provider cache-control hints, prompt caching works through the pipe without extra code. Per-token usage metering is returned in the standard usage field.

Common misconceptions about LCEL pipes

“It’s just f(g(x)).” No. The sequence is a first-class runnable with its own stream, batch, and config propagation. You can serialize its graph, attach callbacks, and inject runtime secrets via config.

“The pipe auto-converts types.” False. If step A returns str and step B expects {"text": str}, you must insert a RunnableLambda to reshape. The pipe will not guess.

“Parallelism requires special operators.” Not exactly. RunnableParallel (often written as a dict literal inside a pipe) is the tool, but it is still a runnable. You can pipe into a dict:

chain = RunnablePassthrough() | {
    "upper": RunnableLambda(str.upper),
    "lower": RunnableLambda(str.lower),
}

“You can’t debug a compiled chain.” Every sequence exposes .get_graph(). Print it:

chain.get_graph().print_ascii()

This shows nodes and edges, which is invaluable when a pipe silently drops a key.

Debugging and observability tips

Attach a callback handler to trace each step’s input and output:

from langchain_core.tracers import ConsoleCallbackHandler

chain.invoke(
    "Explain pipe syntax",
    config={"callbacks": [ConsoleCallbackHandler()]}
)

For structural checks, use chain.input_schema and chain.output_schema (pydantic models) to see what the sequence claims to accept and return. Mismatches between these schemas and your actual data are the source of most runtime errors.

When not to use pipe syntax

If your control flow has dynamic branching based on intermediate values (e.g., “if model returns JSON with field X, call tool A else tool B”), a linear | sequence is the wrong primitive. Use RunnableBranch or write a RunnableLambda that internally dispatches. Forcing conditional logic into pipes via side-effects makes the graph unreadable.

Summary of the mechanics

  • a | bRunnableSequence(a, b)
  • Left output must satisfy right input; no implicit mapping.
  • RunnableParallel (dict) enables concurrency inside a pipe.
  • Streaming and async propagate automatically.
  • The whole chain is itself a runnable, so wrappers (fallbacks, retries, tracing) compose cleanly.

LCEL pipe syntax explained this way gives you a predictable mental model: it is a typed, runtime-aware wiring tool, not a shortcut for function calls. Write the shapes explicitly, print the graph, and the same code scales from notebook to production.

Tagslangchainlcelpipe-syntaxexamples

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain expression language (lcel) chains posts →