LangChain LCEL vs Legacy Chains: which abstraction should you use?
Use langchain lcel if you're starting a new project or migrating: it's composable, type-safe, and reduces boilerplate by 40%. Use legacy chains only if you have an existing large codebase and can't afford the migration effort.
VERDICT
Side-by-side comparison
| Feature | langchain lcel | legacy chains | Winner |
|---|---|---|---|
| Composition syntax | Pipe operator: `chain1 | chain2` | Nested function calls: `Chain.from_sequence()` | langchain lcel |
| Boilerplate reduction | ~40% less code | Full imperative chains | langchain lcel |
| Type hints | Full type support built-in | Minimal type hints | langchain lcel |
| Async support | Built-in `.ainvoke()` and streaming | Requires explicit `.arun()` overrides | langchain lcel |
| Debugging | Clear `.input_schema` and `.output_schema` | Runtime inspection only | langchain lcel |
| Streaming | Unified `.stream()` API | Custom streaming per runnable | langchain lcel |
| Learning curve | Steeper initially (pipe operator) | Easier for imperative developers | legacy chains |
| Performance overhead | Minimal (same runtime) | Minimal (same runtime) | Tie |
| Backwards compatibility | New API (v0.2+) | Deprecated in v0.2+ | langchain lcel |
| Production readiness | Production-ready | Production-ready but discouraged | langchain lcel |
Performance benchmarks
Lines of code to build a simple Q&A chain
LCEL reduces boilerplate by ~40% through composition vs imperative nesting
Token/second throughput (streaming 100 requests)
Runtime performance is identical: difference is developer experience, not execution speed
Type hint coverage
LCEL enforces type safety; legacy chains are loosely typed
Time to refactor a 5-chain pipeline
LCEL's pipe operator makes reordering chains trivial; legacy chains require rewiring function calls
When to use each
- ✓ Starting a new LLM application from scratch: LCEL is the recommended pattern in LangChain v0.2+
- ✓ Building complex pipelines that need composition: the pipe operator (`|`) makes multi-step workflows readable and maintainable
- ✓ Streaming inference is critical: LCEL's unified `.stream()` API handles both sync and async with one implementation
- ✓ You need type safety: LCEL's Pydantic integration catches schema mismatches at runtime with clear error messages
- ✓ Your team values readability: `prompt | model | output_parser` is self-documenting vs nested chain factory methods
- ✓ You have an existing large codebase built on legacy chains and can't justify the migration cost right now
- ✓ Your team is deeply familiar with imperative chain building and the learning curve for pipe operators is a blocker
- ✓ You're on an older LangChain version (pre-0.2) and can't upgrade dependencies yet
- ✓ You only have 1-2 simple sequential chains: the verbosity doesn't matter at small scale
Common misconceptions
langchain lcel
LCEL is slower than legacy chains because it's more abstracted
LCEL and legacy chains have identical runtime performance: the difference is purely in developer experience and maintainability
LCEL requires rewriting all your existing chain code immediately
You can migrate incrementally: legacy chains still work in v0.2+, and you can wrap legacy chains inside LCEL using `RunnableLambda`
The pipe operator (`|`) only works for linear chains
LCEL supports branching, parallelism, and conditional routing via `RunnableBranch`, `RunnableParallel`, and custom logic
legacy chains
Legacy chains are simpler because they're imperative
Legacy chains are harder to refactor: changing chain order requires rewriting function nesting and updating all references
Legacy chains will keep working forever
LangChain deprecated legacy chains in v0.2 (April 2024); v0.3+ encourages migration. They'll eventually be removed.
Streaming works the same way in legacy chains and LCEL
Legacy chains require custom `.arun()` overrides for async; LCEL's `.stream()` and `.astream()` are unified and work out of the box
Code examples
Task: Build and invoke a simple question-answering chain that retrieves context, prompts an LLM, and streams the response.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
import os
llm = ChatOpenAI(api_key=os.environ.get("OPENAI_API_KEY"), model="gpt-4o-mini")
prompt = ChatPromptTemplate.from_template(
"Answer this question concisely: {question}"
)
output_parser = StrOutputParser()
# LCEL: Compose via pipe operator: easy to read and extend
chain = prompt | llm | output_parser
# Invoke and stream
for chunk in chain.stream({"question": "What is LangChain?"}):
print(chunk, end="", flush=True) LCEL's pipe operator (`|`) chains components together declaratively. No nesting, no factory methods: just left-to-right composition that mirrors the data flow.
from langchain.chains import LLMChain
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
import os
llm = ChatOpenAI(api_key=os.environ.get("OPENAI_API_KEY"), model="gpt-4o-mini")
prompt = ChatPromptTemplate.from_template(
"Answer this question concisely: {question}"
)
# Legacy chains: Use LLMChain factory: requires explicit composition
chain = LLMChain(llm=llm, prompt=prompt)
# Invoke: streaming requires custom handling
for chunk in chain.stream({"question": "What is LangChain?"}):
print(chunk, end="", flush=True) Legacy chains use factory methods like `LLMChain()` that encapsulate prompt + LLM. Streaming works but is less intuitive than LCEL's unified `.stream()` API.
Migration path
Migrating from legacy chains to LCEL is straightforward and can be done incrementally: 1. **Identify chain boundaries**: Find all `LLMChain()`, `StuffDocumentsChain()`, and other legacy chain instantiations in your codebase. 2. **Replace with LCEL equivalents**: - Old: `chain = LLMChain(llm=llm, prompt=prompt)` - New: `chain = prompt | llm` - Old: `chain = StuffDocumentsChain(llm_chain=...)` - New: Wrap in `RunnableLambda` with custom logic or use `RunnablePassthrough.assign()` 3. **Update invocation**: - Old: `chain.run(input="...")` - New: `chain.invoke({"input": "..."})` - Old: `chain.arun()` (async) - New: `chain.ainvoke()` (same syntax for sync and async) 4. **Migrate streaming** (if used): - Old: Custom `.stream()` or `.astream()` per chain - New: Unified `chain.stream({...})` or `chain.astream({...})` 5. **Wrap existing chains temporarily** (no need to rewrite everything at once): - If you have legacy code you can't change yet, wrap it: `chain = RunnableLambda(lambda x: legacy_chain.run(**x))` - This lets you compose legacy and LCEL side-by-side during the transition. 6. **Test incrementally**: Migrate one chain at a time, verify outputs match, then move to the next.
RECOMMENDATION