#langchain#lcel#chains#runnableparallel#runnablelambda#runnablepassthrough#chatprompttemplate#python#ollama

LangChain Expression Language & Chains

Master LangChain Expression Language (LCEL) — build sequential, parallel, router, and custom chains using the pipe operator, RunnableParallel, RunnableLambda, and the @chain decorator.

Jun 4, 2026 at 10:30 AM14 min readFollowFollow (Hindi)

Topics You Will Master

What LCEL is and how runnables chain together via the | pipe operator
Building a sequential chain: ChatPromptTemplate | ChatOllama | StrOutputParser
Inspecting the full AIMessage response object from a chain
Extending chains by composing one chain's output into another chain's input
Running multiple chains in parallel with RunnableParallel
Building a router chain that dynamically dispatches to different sub-chains based on sentiment
Using RunnableLambda for custom Python functions and RunnablePassthrough to forward input unchanged
Creating reusable custom chains with the @chain decorator
Best For

Python developers who understand LangChain prompt templates and want to build multi-step, branching, and parallel LLM pipelines using LCEL.

Expected Outcome

A full working toolkit of LCEL chain patterns — sequential, parallel, router, and custom — that you can adapt for any LLM application.

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.

LangChain & Ollama — Local AI Development

Build production-ready LLM apps entirely on your own hardware. No API keys, no cloud costs.

Enroll on Udemy →

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 RunnableLambda or @chain

Setup

PYTHON
from dotenv import load_dotenv

load_dotenv('.env')
OUTPUT
True

On Linux/macOS: adjust the path if .env is in a parent directory: load_dotenv('./../.env')

PYTHON
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
PYTHON
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:

PYTHON
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)
OUTPUT
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:

PYTHON
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
PYTHON
response = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 5})
print(response.content)
OUTPUT
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:

PYTHON
response = chain.invoke({'school': 'phd', 'topics': 'solar system', 'points': 5})
print(response.content)
OUTPUT
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:

PLAINTEXT
response
PLAINTEXT
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:

PYTHON
from langchain_core.output_parsers import StrOutputParser

chain = template | llm | StrOutputParser()
response = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 5})
print(response)
OUTPUT
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:

PLAINTEXT
response
PLAINTEXT
'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:

PLAINTEXT
chain
PYTHON
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.

PYTHON
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)
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:

PYTHON
composed_chain = {"response": chain} | analysis_prompt | llm | StrOutputParser()

output = composed_chain.invoke({'school': 'phd', 'topics': 'solar system', 'points': 5})
print(output)
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

PYTHON
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)
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.
PYTHON
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)
OUTPUT
The sun shines bright, a golden sphere,
Planets dance in silent cheer.

Running Both in Parallel

PYTHON
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:

PYTHON
output = chain.invoke({'school': 'primary', 'topics': 'solar system', 'points': 2, 'sentences': 2})
print(output['fact'])
print('\n\n')
print(output['poem'])
OUTPUT
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

PYTHON
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})
OUTPUT
'Positive'

Step 2 — Sub-Chains for Each Route

PYTHON
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()
PYTHON
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

PYTHON
def rout(info):
    if 'positive' in info['sentiment'].lower():
        return positive_chain
    else:
        return negative_chain

Step 4 — Assembling the Full Router Chain

PYTHON
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:

PYTHON
full_chain
OUTPUT
{
  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

PYTHON
review = "I am not happy with the service. It is not good."

output = full_chain.invoke({'review': review})
print(output)
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

PYTHON
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:

PLAINTEXT
prompt
PYTHON
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

PYTHON
chain = prompt | llm | StrOutputParser()

output = chain.invoke({'input1': 'Earth is planet', 'input2': 'Sun is star'})

print(output)
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):

PYTHON
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)
JSON
{
  "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().

PYTHON
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'])
OUTPUT
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

PYTHON
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 RunnableLambda or the @chain decorator

Every pattern uses the same .invoke() call from the outside — the pipeline handles all the plumbing internally. That's the core promise of LCEL.

Find this tutorial useful?

Subscribe to our YouTube channels for more practical production walk-throughs.

Discussion & Comments