LangChain Expression Language (LCEL): A Practical Guide
LangChain Expression Language, commonly abbreviated as LCEL, is the declarative syntax at the heart of LangChain's modern chain-building workflow. Rather than stitching together components with imperative Python code and verbose class instantiation, LCEL lets you define entire pipelines using a clean, pipe-based syntax. This guide walks you through everything you need to know to start building production-grade chains with LCEL.
What Exactly Is LCEL?
LCEL is a domain-specific language embedded in Python that allows you to compose LangChain components—prompts, models, retrievers, parsers, and more—into a single runnable sequence. The core mechanic is the pipe operator (|), which connects one runnable to the next. Under the hood, each component is wrapped as a Runnable object, and LCEL chains are themselves Runnable instances. This means every chain you build with LCEL automatically inherits a powerful set of interfaces: invoke, stream, batch, and async variants like ainvoke and astream.
Here is the simplest mental model: LCEL turns this verbose, nested approach—
# Old-style imperative chain
from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
prompt = PromptTemplate.from_template("Tell me a joke about {topic}")
model = ChatOpenAI()
chain = LLMChain(llm=model, prompt=prompt)
result = chain.invoke({"topic": "programmers"})
—into this concise, composable expression:
# LCEL-style declarative chain
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("Tell me a joke about {topic}")
model = ChatOpenAI()
chain = prompt | model
result = chain.invoke({"topic": "programmers"})
The pipe operator creates a RunnableSequence where the output of the prompt flows directly into the model. The result is immediately more readable and easier to modify.
Why LCEL Matters
LCEL isn't just syntactic sugar—it fundamentally changes how you build and ship LLM applications. Here are the key benefits:
- First-class streaming support: Every LCEL chain can be streamed token-by-token with
chain.stream()without any extra configuration. The streaming propagates through the entire pipeline automatically. - Built-in async: Calling
chain.ainvoke()orchain.astream()works out of the box, making LCEL chains ideal for high-throughput async web servers. - Automatic parallelization: When you use
RunnableParallel(or the dictionary syntax), independent branches execute concurrently, not sequentially. - Seamless observability: Every step in an LCEL chain emits events that integrate with LangSmith for tracing, debugging, and monitoring.
- Serialization and deployment: LCEL chains serialize cleanly to JSON via
chain.to_json(), enabling easy deployment to LangServe or other serving infrastructure. - Fallbacks and retries: Every runnable supports
.with_fallbacks()and.with_retry(), giving you resilient pipelines without wrapping code in try/except blocks.
Core Building Blocks
Before diving into complex examples, let's establish the fundamental runnable types you'll compose with LCEL:
1. Prompt Templates
Both PromptTemplate and ChatPromptTemplate are first-class runnables. They accept a dictionary of input variables and return a formatted prompt string or message list:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant who responds in {language}."),
("user", "What is the capital of {country}?")
])
# prompt is a Runnable — you can invoke it directly
messages = prompt.invoke({"language": "French", "country": "Belgium"})
2. Language Models
Chat models from any provider—OpenAI, Anthropic, Google, Ollama—are runnables that consume messages and produce message responses:
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o", temperature=0.7)
response = model.invoke("Hello, world!") # string auto-converts to user message
3. Output Parsers
Parsers transform raw model output into structured data. They are runnables that take the model's response and return parsed Python objects:
from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
from langchain_core.pydantic_v1 import BaseModel, Field
class JokeSchema(BaseModel):
setup: str = Field(description="The setup of the joke")
punchline: str = Field(description="The punchline of the joke")
parser = JsonOutputParser(pydantic_object=JokeSchema)
# parser is a Runnable that takes a string/Message and returns a dict
4. Retrievers and Tools
Retrievers, tools, and even custom functions (wrapped with RunnableLambda) integrate seamlessly into LCEL chains:
from langchain_core.runnables import RunnableLambda
def uppercase_text(text: str) -> str:
return text.upper()
uppercase_runnable = RunnableLambda(uppercase_text)
Building Your First LCEL Chain
Let's build a practical chain that generates structured jokes. This example combines a prompt, a model, and a JSON output parser into a single pipeline:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.pydantic_v1 import BaseModel, Field
# Define the desired output schema
class Joke(BaseModel):
setup: str = Field(description="The setup line of the joke")
punchline: str = Field(description="The punchline that delivers the humor")
rating: int = Field(description="Self-rated humor score from 1 to 10", ge=1, le=10)
# Create the parser with format instructions baked in
parser = JsonOutputParser(pydantic_object=Joke)
# Build a prompt that includes the parser's format instructions
prompt = ChatPromptTemplate.from_messages([
("system", "You are a witty comedian. {format_instructions}"),
("user", "Tell me a joke about {topic}")
])
# Partially bind the format instructions so they don't need to be passed each time
prompt = prompt.partial(format_instructions=parser.get_format_instructions())
# Instantiate the model
model = ChatOpenAI(model="gpt-4o", temperature=0.9)
# Compose the chain with LCEL
chain = prompt | model | parser
# Invoke the chain
result = chain.invoke({"topic": "software engineers and coffee"})
print(result)
# Output: {'setup': 'Why do software engineers drink so much coffee?',
# 'punchline': 'Because they need to Java-va their code into action!',
# 'rating': 7}
Notice how the pipe operator creates a clear left-to-right data flow: the input dictionary goes into the prompt, which formats messages; those messages go into the model, which generates a response; that response goes into the parser, which produces a structured Python dictionary. Each step is transparent and debuggable.
Parallel Execution with RunnableParallel
One of LCEL's most powerful features is the ability to run multiple branches concurrently. You can express parallelism using a dictionary literal or the RunnableParallel class. Here's an example that simultaneously fetches information from two different sources and feeds both into a synthesis prompt:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnableLambda
import requests
import asyncio
model = ChatOpenAI(model="gpt-4o")
# Simulate two independent data-fetching functions
def get_weather(city: str) -> str:
# In production, call a real weather API here
return f"Weather in {city}: Sunny, 72°F"
def get_news(city: str) -> str:
# In production, call a news API here
return f"Top news in {city}: Tech conference draws 10,000 attendees"
# Wrap them as runnables
weather_runnable = RunnableLambda(get_weather)
news_runnable = RunnableLambda(get_news)
# Create a parallel branch — both functions receive the same input
parallel_fetcher = RunnableParallel(
weather=weather_runnable,
news=news_runnable
)
# Build a synthesis prompt
synthesis_prompt = ChatPromptTemplate.from_template(
"Given the following information about a city:\n\n"
"Weather: {weather}\n"
"Local News: {news}\n\n"
"Write a short travel advisory paragraph for a tourist visiting today."
)
# Compose the full chain
chain = parallel_fetcher | synthesis_prompt | model
# Invoke — both get_weather and get_news execute concurrently
result = chain.invoke("San Francisco")
print(result.content)
The dictionary passed to RunnableParallel defines named branches. The input flows to each branch simultaneously, and the results are merged into a dictionary with the same keys. This merged dictionary then feeds into the downstream prompt. For I/O-bound operations like API calls, this parallelism dramatically reduces latency.
Dynamic Routing with RunnableBranch
LCEL supports conditional logic through RunnableBranch, which evaluates a series of conditions and routes input to the first matching branch. This is the LCEL equivalent of if/elif/else:
from langchain_core.runnables import RunnableBranch, RunnableLambda
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
model = ChatOpenAI(model="gpt-4o")
# Define specialized prompts for different query types
technical_prompt = ChatPromptTemplate.from_template(
"You are a senior engineer. Answer this technical question in detail: {query}"
)
general_prompt = ChatPromptTemplate.from_template(
"You are a helpful assistant. Answer this question concisely: {query}"
)
creative_prompt = ChatPromptTemplate.from_template(
"You are a creative writer. Answer with flair and imagination: {query}"
)
# Define routing conditions using RunnableLambda
def is_technical(input_dict: dict) -> bool:
tech_keywords = ["code", "bug", "api", "database", "algorithm", "deploy"]
query = input_dict.get("query", "").lower()
return any(keyword in query for keyword in tech_keywords)
def is_creative(input_dict: dict) -> bool:
creative_keywords = ["story", "poem", "imagine", "creative", "design"]
query = input_dict.get("query", "").lower()
return any(keyword in query for keyword in creative_keywords)
# Build the branch: (condition, runnable) pairs with a default fallback
branch = RunnableBranch(
(RunnableLambda(is_technical), technical_prompt),
(RunnableLambda(is_creative), creative_prompt),
general_prompt # default branch — no condition needed
)
# Compose the full chain
chain = branch | model
# Test different queries
tech_result = chain.invoke({"query": "How do I fix a null pointer bug in my API?"})
general_result = chain.invoke({"query": "What is the capital of Portugal?"})
creative_result = chain.invoke({"query": "Write a short poem about the ocean"})
print(tech_result.content[:80]) # Routes to technical_prompt
print(general_result.content[:80]) # Routes to general_prompt
print(creative_result.content[:80]) # Routes to creative_prompt
RunnableBranch evaluates conditions in order. The first condition that returns a truthy value wins, and its associated runnable receives the input. If no condition matches, the default runnable (the last positional argument without a condition) handles the input. This pattern keeps routing logic declarative and tightly integrated with your chain.
Adding Memory and Conversation History
LCEL chains integrate with LangChain's memory system through RunnableWithMessageHistory. This wrapper automatically manages conversation history, injecting past messages into each invocation and appending the new exchange to the stored history:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
model = ChatOpenAI(model="gpt-4o")
# Build a prompt with a placeholder for conversation history
prompt = ChatPromptTemplate.from_messages([
("system", "You are a knowledgeable historian specializing in ancient civilizations."),
MessagesPlaceholder(variable_name="history"),
("user", "{question}")
])
# Create the base chain
base_chain = prompt | model
# In-memory store for session histories (use a database in production)
store = {}
def get_session_history(session_id: str) -> ChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
# Wrap the chain with message history support
chain_with_history = RunnableWithMessageHistory(
base_chain,
get_session_history,
input_messages_key="question",
history_messages_key="history"
)
# Simulate a multi-turn conversation
session_id = "user_123_session_1"
response1 = chain_with_history.invoke(
{"question": "Who was Cleopatra?"},
config={"configurable": {"session_id": session_id}}
)
print(response1.content[:100])
response2 = chain_with_history.invoke(
{"question": "What was her relationship with Julius Caesar like?"},
config={"configurable": {"session_id": session_id}}
)
print(response2.content[:100])
# The model remembers the context from the first question
The configurable key in the config dictionary passes the session_id to the history retrieval function. LCEL handles the rest: fetching past messages, injecting them into the prompt, and saving the new exchange after the model responds.
Streaming and Async Patterns
Every LCEL chain supports streaming out of the box. The stream method yields tokens as they're generated, which is perfect for real-time UIs. The async variants follow the same pattern:
import asyncio
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
model = ChatOpenAI(model="gpt-4o", temperature=0.7)
prompt = ChatPromptTemplate.from_template(
"Explain {concept} in exactly three short bullet points."
)
chain = prompt | model
# Synchronous streaming
print("=== Synchronous streaming ===")
for chunk in chain.stream({"concept": "quantum entanglement"}):
print(chunk.content, end="", flush=True)
print("\n")
# Asynchronous streaming
async def async_stream():
print("=== Asynchronous streaming ===")
async for chunk in chain.astream({"concept": "neural networks"}):
print(chunk.content, end="", flush=True)
print("\n")
asyncio.run(async_stream())
# Batch processing for multiple inputs
inputs = [
{"concept": "recursion"},
{"concept": "entropy"},
{"concept": "blockchain"}
]
results = chain.batch(inputs)
for i, result in enumerate(results):
print(f"Result {i+1}: {result.content[:60]}...")
The batch method efficiently processes multiple inputs, and for chains with parallel branches, it can overlap execution across both inputs and internal branches for maximum throughput.
Error Handling with Fallbacks
LCEL provides declarative fallback chains through the with_fallbacks method. This lets you specify alternative runnables to execute if the primary one fails, without writing manual try/except logic:
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template(
"Summarize the following text in one sentence: {text}"
)
# Primary model — might fail due to rate limits or outages
primary_model = ChatOpenAI(model="gpt-4o")
# Fallback models — tried in order if the primary fails
fallback_model_1 = ChatAnthropic(model="claude-3-haiku-20240307")
fallback_model_2 = ChatOpenAI(model="gpt-3.5-turbo")
# Create a resilient model with fallbacks
resilient_model = primary_model.with_fallbacks([fallback_model_1, fallback_model_2])
# Compose the full chain
chain = prompt | resilient_model
# This invocation automatically falls back if gpt-4o fails
result = chain.invoke({
"text": "The quick brown fox jumps over the lazy dog. " * 50
})
print(result.content)
Fallbacks are tried in the order provided. If primary_model raises an exception, LCEL catches it and routes the same input to fallback_model_1. If that also fails, fallback_model_2 gets a chance. This pattern is invaluable for building resilient production pipelines.
Custom Runnable Functions with RunnableLambda
Not every useful step in a chain is a LangChain component. RunnableLambda wraps any Python callable into a full-fledged runnable that participates in streaming, batching, and async execution:
from langchain_core.runnables import RunnableLambda
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
import json
import re
model = ChatOpenAI(model="gpt-4o")
# Custom preprocessing step
def sanitize_input(input_dict: dict) -> dict:
"""Remove potentially problematic characters from user input."""
text = input_dict.get("text", "")
# Strip HTML tags and limit length
cleaned = re.sub(r'<[^>]+>', '', text)[:2000]
return {"text": cleaned, "original_length": len(text)}
# Custom postprocessing step
def extract_keywords(response) -> dict:
"""Extract capitalized words as potential keywords from the response."""
content = response.content if hasattr(response, 'content') else str(response)
keywords = re.findall(r'\b[A-Z][a-z]{3,}\b', content)
return {"summary": content, "keywords": list(set(keywords))}
# Wrap both as runnables
sanitize_runnable = RunnableLambda(sanitize_input)
keyword_runnable = RunnableLambda(extract_keywords)
# Build prompt
prompt = ChatPromptTemplate.from_template(
"Summarize the following text in 2-3 sentences:\n\n{text}"
)
# Compose the chain with custom steps
chain = sanitize_runnable | prompt | model | keyword_runnable
# Invoke
result = chain.invoke({
"text": "Machine Learning and Artificial Intelligence are transforming "
"industries across Healthcare, Finance, and Education. Researchers "
"at Stanford and MIT continue to push boundaries.
"
})
print(json.dumps(result, indent=2))
# Output includes both the summary and extracted keywords
RunnableLambda automatically adapts to the input type. If you pass a dictionary, your function receives a dictionary. If you pass a string, it receives a string. The return value becomes the input to the next component in the chain.
Advanced Composition: Merging and Branching
Real-world applications often require complex topologies where data flows split, transform independently, and merge back together. LCEL handles this elegantly with nested RunnableParallel structures:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
model = ChatOpenAI(model="gpt-4o")
# Branch 1: Generate a creative tagline
tagline_prompt = ChatPromptTemplate.from_template(
"Create a catchy 5-word tagline for a product described as: {product_description}"
)
tagline_chain = tagline_prompt | model | StrOutputParser()
# Branch 2: Generate SEO keywords
seo_prompt = ChatPromptTemplate.from_template(
"List exactly 5 SEO keywords for a product described as: {product_description}. "
"Return only the keywords separated by commas, no other text."
)
seo_chain = seo_prompt | model | StrOutputParser()
# Branch 3: Analyze sentiment of the product description
sentiment_prompt = ChatPromptTemplate.from_template(
"Analyze the sentiment of this product description as Positive, Neutral, or Negative. "
"Return only the single word.\n\nDescription: {product_description}"
)
sentiment_chain = sentiment_prompt | model | StrOutputParser()
# Combine all three branches in parallel
parallel_marketing_chain = RunnableParallel(
tagline=tagline_chain,
seo_keywords=seo_chain,
sentiment=sentiment_chain
)
# Add a final synthesis step
synthesis_prompt = ChatPromptTemplate.from_template(
"You are a marketing director. Based on the following product analysis:\n\n"
"Tagline: {tagline}\n"
"SEO Keywords: {seo_keywords}\n"
"Sentiment: {sentiment}\n\n"
"Write a concise marketing strategy recommendation (3-4 sentences)."
)
full_chain = parallel_marketing_chain | synthesis_prompt | model | StrOutputParser()
# Invoke the complex chain
result = full_chain.invoke({
"product_description": "An eco-friendly reusable water bottle made from recycled "
"ocean plastics, featuring a built-in purification filter "
"and temperature display."
})
print(result)
This example demonstrates three independent analyses running concurrently, their results merging into a single dictionary, which then feeds a synthesis prompt. The entire topology is expressed declaratively without managing threads or async tasks manually.
Best Practices for LCEL
- Start simple and compose incrementally: Build small, testable subchains and combine them. Debug each runnable in isolation with
.invoke()before integrating it into a larger pipeline. - Use type hints and output parsers early: Define Pydantic schemas for structured outputs. This catches parsing errors at chain construction time rather than at runtime deep in a production pipeline.
- Leverage
partialbinding for static variables: Useprompt.partial()to bind variables that don't change between invocations (like format instructions or system contexts). This keeps your invoke calls clean and focused on dynamic inputs. - Name your runnables for observability: Use
.with_config({"run_name": "meaningful_name"})on each component. These names appear in LangSmith traces, making debugging significantly easier. - Test fallback chains explicitly: Simulate failures in development to ensure your fallback hierarchy behaves as expected. A misconfigured fallback that silently swallows errors is worse than no fallback at all.
- Prefer
RunnableLambdaover custom classes for simple transforms: If your custom logic is a single function, wrapping it withRunnableLambdais lighter and more composable than implementing a full customRunnablesubclass. - Use
RunnableParallelfor I/O-bound work, not CPU-bound: The parallelism in LCEL is ideal for overlapping API calls and database queries. For CPU-heavy computation, consider offloading to a separate worker pool. - Serialize your chains for deployment: Once a chain works, call
chain.to_json()and store the configuration. This enables reproducible deployments via LangServe or your own serving infrastructure.
Conclusion
LangChain Expression Language transforms the experience of building LLM-powered applications from imperative plumbing into declarative composition. By expressing chains as sequences of pipe-connected runnables, you gain automatic streaming, async support, parallel execution, fallback handling, and deep observability—all without writing extra infrastructure code. The patterns covered in this guide—from basic prompt-model chains to complex parallel topologies with routing and memory—give you a solid foundation for building production-ready systems. As you continue exploring LCEL, remember that every chain is itself a runnable, meaning you can compose chains into higher-order chains, test them in isolation, and deploy them with confidence. The declarative approach scales from a single prompt-model pair all the way up to multi-branch, multi-model architectures that power real-world applications.