LangChain Expression Language (LCEL) is a declarative way to compose LangChain components (called runnables) into pipelines. Any two runnables can be chained together: the output of the first is automatically passed as the input to the next. The | pipe operator is the primary syntax; .pipe() is its explicit equivalent.
This tutorial covers every LCEL chain pattern: sequential, parallel, router, custom runnables, and the @chain decorator.
Prerequisites: LangChain, langchain-ollama, and python-dotenv installed. Ollama running locally with qwen3 pulled. See the Prompt Templates guide.
LCEL Fundamentals
LCEL chains work on a single principle: runnables pass output forward. The .invoke() return value of one runnable becomes the input of the next.
Chain types covered in this lesson:
- Sequential Chain — steps run one after another in a linear pipeline
- Parallel Chain — multiple sub-chains run concurrently, results returned as a dict
- Router Chain — output of a first step decides which sub-chain handles the next step
- Chaining Runnables — one chain's string output feeds into another chain's prompt variable
- Custom Chain — arbitrary Python functions wrapped as runnables with
RunnableLambdaor@chain
Setup
from dotenv import load_dotenv
load_dotenv('.env')
True
On Linux/macOS: adjust the path if .env is in a parent directory: load_dotenv('./../.env')
from langchain_ollama import ChatOllama
from langchain_core.prompts import (
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
ChatPromptTemplate
)
base_url = "http://localhost:11434"
model = 'qwen3'
llm = ChatOllama(base_url=base_url, model=model)
llm
ChatOllama(model='qwen3', base_url='http://localhost:11434')
Sequential LCEL Chain
The Manual Baseline (Without LCEL)
Before introducing the | operator, here is the explicit two-step approach — create a prompt, invoke it, then invoke the LLM:
system = SystemMessagePromptTemplate.from_template(
'You are {school} teacher. You answer in short sentences.'
)
question = HumanMessagePromptTemplate.from_template(
'tell me about the {topics} in {points} points'
)
messages = [system, question]
template = ChatPromptTemplate(messages)
question = template.invoke({'school': 'primary', 'topics': 'solar system', 'points': 5})
response = llm.invoke(question)
print(response.content)
1. The Sun is the center, holding the solar system together with gravity.
2. Eight planets orbit the Sun: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.
3. The asteroid belt lies between Mars and Jupiter, with rocky objects.
4. Gas giants (Jupiter, Saturn, Uranus, Neptune) are large and mostly made of gas.
5. Distant regions like the Kuiper Belt and Oort Cloud contain icy bodies and comets.
Building the Chain with |
LCEL collapses the above two steps into a single chainable expression. Define the chain once, then call .invoke() directly on it with a variable dict:
system = SystemMessagePromptTemplate.from_template(
'You are {school} teacher. You answer in short sentences.'
)
question = HumanMessagePromptTemplate.from_template(
'tell me about the {topics} in {points} points'
)
messages = [system, question]
template = ChatPromptTemplate(messages)
chain = template | llm
response = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 5})
print(response.content)
1. The Sun is the center, holding the solar system together with gravity.
2. Eight planets orbit the Sun: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.
3. The asteroid belt lies between Mars and Jupiter, with rocky objects.
4. Moons orbit planets, like Earth's Moon and Jupiter's many moons.
5. The Kuiper Belt and Oort Cloud are regions of icy bodies beyond Neptune.
Switch the school variable to get a different depth of explanation — same chain, no code change:
response = chain.invoke({'school': 'phd', 'topics': 'solar system', 'points': 5})
print(response.content)
1. The Sun is the central star, providing gravity and energy.
2. Eight planets orbit it: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune.
3. Smaller bodies include asteroids, comets, and dwarf planets like Pluto.
4. The solar system has distinct regions: inner rocky planets, outer gas giants, asteroid belt, and Kuiper Belt.
5. It formed from a collapsing cloud of gas and dust around 4.6 billion years ago.
Inspecting the Full AIMessage Response
Without a parser, chain.invoke() returns a full AIMessage object containing content, response_metadata, token counts, and a run ID:
response
AIMessage(content='1. The Sun is the central star, providing gravity and energy. \n2. Eight planets orbit it: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune. \n3. Smaller bodies include asteroids, comets, and dwarf planets like Pluto. \n4. The solar system has distinct regions: inner rocky planets, outer gas giants, asteroid belt, and Kuiper Belt. \n5. It formed from a collapsing cloud of gas and dust around 4.6 billion years ago.', additional_kwargs={}, response_metadata={'model': 'qwen3', 'created_at': '2025-10-22T06:48:23.0623152Z', 'done': True, 'done_reason': 'stop', 'total_duration': 2236060800, 'load_duration': 69630100, 'prompt_eval_count': 37, 'prompt_eval_duration': 15459300, 'eval_count': 402, 'eval_duration': 2052522300, 'model_name': 'qwen3', 'model_provider': 'ollama'}, id='lc_run--d02d48ee-498c-4a32-949a-dceb964ae040-0', usage_metadata={'input_tokens': 37, 'output_tokens': 402, 'total_tokens': 439})
Adding StrOutputParser
Append StrOutputParser to extract the plain text string instead of the full AIMessage:
from langchain_core.output_parsers import StrOutputParser
chain = template | llm | StrOutputParser()
response = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 5})
print(response)
1. The Sun is the center, holding the solar system together with gravity.
2. Eight planets orbit the Sun: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.
3. The asteroid belt lies between Mars and Jupiter, with rocky objects.
4. Moons orbit planets, like Earth's Moon and Jupiter's many moons.
5. Distant regions include the Kuiper Belt and Oort Cloud, home to icy bodies.
Evaluate response directly — it is now a plain Python string, not a message object:
response
'1. The Sun is the center, holding the solar system together with gravity. \n2. Eight planets orbit the Sun: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune. \n3. The asteroid belt lies between Mars and Jupiter, with rocky objects. \n4. Moons orbit planets, like Earth\'s Moon and Jupiter\'s many moons. \n5. Distant regions include the Kuiper Belt and Oort Cloud, home to icy bodies.'
Inspecting chain shows the full pipeline object with all three components displayed sequentially:
chain
ChatPromptTemplate(input_variables=['points', 'school', 'topics'], ...)
| ChatOllama(model='qwen3', base_url='http://localhost:11434')
| StrOutputParser()
Chaining Runnables (Composed Chains)
You can feed the string output of one chain directly into the prompt variable of a second chain. This lets you run analysis or transformations on LLM output as a single .invoke() call.
analysis_prompt = ChatPromptTemplate.from_template('''analyze the following text: {response}
You need tell me that how difficult it is to understand.
Answer in one sentence only.
''')
fact_check_chain = analysis_prompt | llm | StrOutputParser()
output = fact_check_chain.invoke({'response': response})
print(output)
The text is easy to understand as it presents basic, clear information about the solar system using simple language and familiar concepts.
To run both chains end-to-end in one call, compose them using a dict that maps chain output to the next prompt's variable:
composed_chain = {"response": chain} | analysis_prompt | llm | StrOutputParser()
output = composed_chain.invoke({'school': 'phd', 'topics': 'solar system', 'points': 5})
print(output)
The text is relatively simple and accessible, requiring basic knowledge of astronomy concepts like planetary orbits, dwarf planets, and the solar system's structure.
Note
{"response": chain} is LCEL shorthand for a RunnableParallel with a single key. The value of "response" is populated by running chain with the same input dict, and the result is passed to analysis_prompt as {response}.
Parallel LCEL Chain
Parallel chains run multiple sub-chains concurrently on the same input and return results as a dict. Use RunnableParallel to define the parallel structure.
Building Two Sub-Chains
system = SystemMessagePromptTemplate.from_template(
'You are {school} teacher. You answer in short sentences.'
)
question = HumanMessagePromptTemplate.from_template(
'tell me about the {topics} in {points} points'
)
messages = [system, question]
template = ChatPromptTemplate(messages)
fact_chain = template | llm | StrOutputParser()
output = fact_chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 2})
print(output)
1. The solar system has the Sun at its center, with eight planets orbiting around it.
2. It includes moons, asteroids, comets, and other celestial objects in space.
question = HumanMessagePromptTemplate.from_template(
'write a poem on {topics} in {sentences} lines'
)
messages = [system, question]
template = ChatPromptTemplate(messages)
poem_chain = template | llm | StrOutputParser()
output = poem_chain.invoke({'school': 'primary', 'topics': 'solar system', 'sentences': 2})
print(output)
The sun shines bright, a golden sphere,
Planets dance in silent cheer.
Running Both in Parallel
from langchain_core.runnables import RunnableParallel
chain = RunnableParallel(fact=fact_chain, poem=poem_chain)
Call .invoke() with a dict containing all variables required by both sub-chains:
output = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 2, 'sentences': 2})
print(output['fact'])
print('\n\n')
print(output['poem'])
1. The solar system has the Sun at its center, with eight planets orbiting around it.
2. It includes moons, asteroids, comets, and dwarf planets like Pluto, all held by gravity.
The sun, a fiery heart, spins bright and bold,
planets dance in orbits, each a world of gold.
Tip
RunnableParallel runs both chains concurrently in separate threads. For local LLM calls this reduces total wall-clock time compared to running them sequentially.
Router Chain
A router chain classifies the input first, then dispatches to a different sub-chain based on that classification. The router decision is made at runtime.
Step 1 — Sentiment Classifier Chain
prompt = """Given the user review below, classify it as either being about `Positive` or `Negative`.
Do not respond with more than one word.
Review: {review}
Classification:"""
template = ChatPromptTemplate.from_template(prompt)
chain = template | llm | StrOutputParser()
review = "Thank you so much for providing such a great plateform for learning. I am really happy with the service."
chain.invoke({'review': review})
'Positive'
Step 2 — Sub-Chains for Each Route
positive_prompt = """
You are expert in writing reply for positive reviews.
You need to encourage the user to share their experience on social media.
Review: {review}
Answer:"""
positive_template = ChatPromptTemplate.from_template(positive_prompt)
positive_chain = positive_template | llm | StrOutputParser()
negative_prompt = """
You are expert in writing reply for negative reviews.
You need first to apologize for the inconvenience caused to the user.
You need to encourage the user to share their concern on following Email:'udemy@kgptalkie.com'.
Review: {review}
Answer:"""
negative_template = ChatPromptTemplate.from_template(negative_prompt)
negative_chain = negative_template | llm | StrOutputParser()
Step 3 — Router Function
def rout(info):
if 'positive' in info['sentiment'].lower():
return positive_chain
else:
return negative_chain
Step 4 — Assembling the Full Router Chain
from langchain_core.runnables import RunnableLambda
full_chain = {"sentiment": chain, 'review': lambda x: x['review']} | RunnableLambda(rout)
Inspect full_chain to see the complete pipeline structure:
full_chain
{
sentiment: ChatPromptTemplate(...)
| ChatOllama(model='qwen3', base_url='http://localhost:11434')
| StrOutputParser(),
review: RunnableLambda(lambda x: x['review'])
}
| RunnableLambda(rout)
Step 5 — Invoking with a Negative Review
review = "I am not happy with the service. It is not good."
output = full_chain.invoke({'review': review})
print(output)
We sincerely apologize for the inconvenience caused and understand your dissatisfaction. We value your feedback and kindly ask you to share your concern via email at udemy@kgptalkie.com so that we can address your issue promptly. We are committed to resolving this matter to your satisfaction and appreciate your patience as we work to improve our services. Thank you for bringing this to our attention.
Note
Change review to a positive string and the router automatically dispatches to positive_chain instead, with no code change needed.
Custom Runnables with RunnablePassthrough and RunnableLambda
RunnableLambda wraps any Python function as a runnable step. RunnablePassthrough forwards its input unchanged — useful for passing the original text alongside derived values.
Helper Functions and Prompt
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
def char_counts(text):
return len(text)
def word_counts(text):
return len(text.split())
prompt = ChatPromptTemplate.from_template("Explain these inputs in 5 sentences: {input1} and {input2}")
Inspect the prompt to confirm auto-detected variables:
prompt
ChatPromptTemplate(input_variables=['input1', 'input2'], input_types={}, partial_variables={}, messages=[HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['input1', 'input2'], ..., template='Explain these inputs in 5 sentences: {input1} and {input2}'), additional_kwargs={})])
Plain Chain Output
chain = prompt | llm | StrOutputParser()
output = chain.invoke({'input1': 'Earth is planet', 'input2': 'Sun is star'})
print(output)
1. Earth is a planet, meaning it is a celestial body that orbits the Sun, has a solid surface, and meets specific criteria like clearing its orbit of other debris.
2. The Sun is a star, a massive, luminous sphere of plasma held together by gravity, where nuclear fusion powers its light and heat.
3. Planets, like Earth, do not produce their own light but reflect sunlight, while stars, like the Sun, generate energy through nuclear reactions.
4. Earth's classification as a planet distinguishes it from stars, which are much larger and emit light due to internal heat and fusion.
5. The Sun's role as a star is central to sustaining life on Earth, as its energy drives weather, climate, and the planet's orbital motion.
Enriched Output with Counts and Passthrough
Extend the chain so that after StrOutputParser returns the text, three parallel steps run: char_counts, word_counts, and RunnablePassthrough (which forwards the raw string as output):
chain = prompt | llm | StrOutputParser() | {
'char_counts': RunnableLambda(char_counts),
'word_counts': RunnableLambda(word_counts),
'output': RunnablePassthrough()
}
output = chain.invoke({'input1': 'Earth is planet', 'input2': 'Sun is star'})
print(output)
{
"char_counts": 719,
"word_counts": 122,
"output": "1. Earth is a planet, meaning it is a celestial body that orbits the Sun and is characterized by its solid surface, atmosphere, and ability to support life. \n2. The Sun is a star, a massive, luminous sphere of plasma held together by gravity, which generates energy through nuclear fusion in its core. \n3. Planets like Earth are much smaller and cooler than stars, and they do not produce their own light but reflect sunlight. \n4. The Sun's gravitational pull keeps Earth and other planets in orbit, forming the solar system. \n5. While Earth is a planet, the Sun's status as a star highlights the fundamental difference between these two types of celestial objects in terms of size, composition, and energy sources."
}
Tip
RunnablePassthrough is the cleanest way to forward a value through a pipeline step without transformation. It avoids the need for a no-op lambda.
Custom Chain with the @chain Decorator
The @chain decorator from langchain_core.runnables converts any Python function into a fully LCEL-compatible runnable — it can be used with |, .invoke(), .stream(), and .batch().
from langchain_core.runnables import chain
@chain
def custom_chain(params):
return {
'fact': fact_chain.invoke(params),
'poem': poem_chain.invoke(params),
}
params = {'school': 'primary', 'topics': 'solar system', 'points': 2, 'sentences': 2}
output = custom_chain.invoke(params)
print(output['fact'])
print('\n\n')
print(output['poem'])
1. The solar system has the Sun at its center.
2. Eight planets orbit the Sun in elliptical paths.
The sun reigns bright, a golden core,
Planets dance in orbits, vast and wide.
Note
Unlike RunnableParallel, the @chain decorator runs sub-chains sequentially inside the function. Use RunnableParallel when true concurrency matters.
On Linux/macOS: all code above runs identically — no OS-specific differences.
Quick Reference
LCEL Chain Patterns
| Pattern | Syntax | Use when |
|---|---|---|
| Sequential | a | b | c |
Steps run in order, each output feeds next |
| With parser | template | llm | StrOutputParser() |
Want a plain string instead of AIMessage |
| Composed | {"key": chain} | next_prompt | llm |
First chain's output becomes a variable in next prompt |
| Parallel | RunnableParallel(a=chain1, b=chain2) |
Run multiple chains concurrently |
| Router | {...} | RunnableLambda(router_fn) |
Dispatch to different chains based on runtime value |
| Custom | @chain decorator |
Wrap arbitrary Python logic as a runnable |
Key Imports
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import (
RunnableParallel,
RunnableLambda,
RunnablePassthrough,
chain
)
What You Built
In this lesson you moved from manually calling template.invoke() + llm.invoke() to composing fully declarative LCEL pipelines.
You now have five patterns in your toolkit:
- Sequential — the workhorse:
template | llm | StrOutputParser() - Composed — feeding one chain's string output as a variable into the next chain's prompt
- Parallel — running a fact chain and a poem chain simultaneously with
RunnableParallel, getting both results in one dict - Router — classifying a review as positive or negative first, then dispatching to the appropriate reply chain at runtime
- Custom — wrapping arbitrary Python logic as a first-class LCEL runnable with
RunnableLambdaor the@chaindecorator
Every pattern uses the same .invoke() call from the outside — the pipeline handles all the plumbing internally. That's the core promise of LCEL.