Skip to content

Migrating a LangChain Chain to LCEL

11 min read · updated August 11, 2026

LCEL is not a nicer way to write an LLMChain. It is a different composition model, and the pieces that translate cleanly translate in about a minute while the pieces that do not are where the whole migration actually lives. Start with the one that translates.

The deprecation you are answering

The warning reads roughly: LangChainDeprecationWarning: The class `LLMChain` was deprecated in LangChain 0.1.17 and will be removed in 1.0. Use RunnableSequence, e.g., `prompt | llm` instead. The removal version quoted in that string has itself changed between releases, so read the one your install prints rather than a version from a blog post. The instruction in it is accurate and unusually specific: the replacement for a chain is a composed runnable, and the composition operator is |.

Two things are happening under that one warning. The class is going away, and the interface is changing: .run(), .predict() and calling the chain directly are all replaced by .invoke(), with .batch(), .stream() and their async counterparts available on the same object for free.

The rewrite, one chain

Here is an ordinary summarising chain, before:

from langchain.chains import LLMChain
from langchain.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_template(
    "Summarise this in two sentences:\n\n{document}"
)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

chain = LLMChain(llm=llm, prompt=prompt)
summary = chain.run(document=text)          # -> str

And after:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_template(
    "Summarise this in two sentences:\n\n{document}"
)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

chain = prompt | llm | StrOutputParser()
summary = chain.invoke({"document": text})  # -> str

Three differences to notice, because each of them is a class of edit you will repeat across the codebase. The imports moved to langchain_core. The input became a dictionary passed positionally rather than keyword arguments. And StrOutputParser() is not optional decoration — without it the chain returns an AIMessage object, and everything downstream that expected a string gets one with a .content attribute instead.

That third one is the single most common post-migration bug, and it usually surfaces as a template rendering something like content='...' response_metadata=... into user-facing text rather than as an exception.

What the return type change costs you

LLMChain had an output key. Called with __call__ rather than run, it returned a dictionary — the input keys plus text — and code downstream often reached in for a named field. A composed runnable returns whatever the last element returns, and nothing more. If callers depended on getting the inputs back alongside the output, that is now your job:

from langchain_core.runnables import RunnablePassthrough

chain = RunnablePassthrough.assign(
    summary=prompt | llm | StrOutputParser()
)
chain.invoke({"document": text})
# -> {"document": "...", "summary": "..."}

This is the general shape for anything that used to be a SequentialChain with named intermediate keys: RunnablePassthrough.assign adds a key to a dictionary flowing through the chain, so a two-stage pipeline becomes one dictionary that accumulates fields rather than two chains wired by name.

The other lost affordance is verbose=True. It printed the rendered prompt and the raw completion, which is how a great many people debugged. Composed runnables do not have it. The replacements are real tracing (the framework’s own tracing integration, or any OpenTelemetry-based tooling) or, for a one-off, invoking the prompt alone: prompt.invoke({"document": text}) returns the rendered messages without calling a model, which answers most of the questions verbose was used for and costs nothing.

Chains with no direct equivalent

These are the ones that turn a one-hour migration into a one-day one. Each of them bundled a decision that composition makes explicit, which is the point, and also the work.

  • RetrievalQA — became a composition of a document-combining step and a retrieval step, built by helper constructors rather than by one class. The behaviour you lose silently is the default prompt: the old class shipped one, and if you never wrote a prompt, you now have to, and the new one will not be word-for-word the old one. Expect output to change, and diff the outputs before and after rather than assuming parity.
  • ConversationChain and the memory classes — the largest gap. Memory was a mutable object attached to a chain, which is precisely the stateful thing a composition of pure functions is not. History is now threaded in as an input to the chain, with a wrapper responsible for loading and saving it per session id. The buffer-window and summary-memory variants have no drop-in form at all; you re-implement the trimming or summarising policy as an explicit step.
  • MapReduceDocumentsChain and its refine sibling — these are control flow, not composition. The map half is .batch() over a list, which is easy. The reduce half is a loop with a token-budget condition, and it stays a loop you write.
  • Agent executors — the old executor was a while-loop with a stopping condition. The direction of travel is toward an explicit graph rather than a pipe, and trying to express a tool-calling loop as a linear | chain is the wrong shape. Do not force it.

What you get that the old chain could not do

Worth knowing before you decide how much of this to do, because several of these replace code you currently maintain by hand.

  • Streaming for free. .stream() works on any composition whose parts can stream, so a chain becomes streamable without being rewritten. On LLMChain this needed callbacks.
  • Batching for free. .batch() runs the inputs concurrently rather than sequentially, which is the single largest throughput change available on most retrieval pipelines.
  • .with_fallbacks() and .with_retry() — both are methods on any runnable, so a fallback to a second model is a wrapper rather than a try/except around the call site.
  • .bind() — fixes an argument on the model for this composition only, which is how tool definitions and stop sequences get attached without a second model instance.

Migrating a real codebase

  1. Fix imports first, as their own commit, so that the diff you are about to read is composition changes rather than import churn.
  2. Capture outputs before you change anything. Run a fixed set of inputs through the current chains and store the results. This is the only way to tell a behaviour change from a refactor afterwards.
  3. Convert the pure LLMChain instances. Add StrOutputParser() to every one, then fix the call sites from run(x=...) to invoke({"x": ...}).
  4. Convert SequentialChain instances using RunnablePassthrough.assign, one key per stage.
  5. Handle retrieval and memory chains last and individually. Write the prompt explicitly rather than inheriting a default, and re-run the captured inputs to see what moved.
  6. Leave agent loops alone until the rest passes. They are a different migration and mixing them in makes both harder to review.