LangChain Prompt Templates: Dynamic Prompts with Variables and Chat History
Every real LLM application sends the same prompt structure over and over — a system instruction, some context, maybe a few examples — with only the specifics changing each time. Hard-coding those prompts as f-[strings](/python/python-string-formatting/) works for a demo. It falls apart the moment you need to swap models, track history, or share prompt logic across a team.
LangChain prompt templates fix this by turning prompts into composable, reusable objects. This tutorial covers ChatPromptTemplate, message roles, partial binding, MessagesPlaceholder for chat history, few-shot templates, and how to pipe everything into a model with LCEL.
What Are Prompt Templates and Why Use Them?
I spent a lot of time early on building LLM apps with raw f-strings. It worked fine until I had three endpoints using slightly different versions of the same prompt. A single wording change meant editing code in three places, and every edit risked introducing a subtle mismatch.
A prompt template is a reusable blueprint for generating prompts. You define it once with placeholder variables, then fill in those variables at call time. Here is the core idea:
topic = "recursion"
difficulty = "beginner"
prompt = f"""You are a Python tutor.
Explain {topic} at a {difficulty} level.
Use one concrete example."""
print(prompt)from langchain_core.prompts import ChatPromptTemplate
template = ChatPromptTemplate.from_messages([
("system", "You are a Python tutor."),
("human", "Explain {topic} at a {difficulty} level. Use one concrete example."),
])
prompt = template.invoke({"topic": "recursion", "difficulty": "beginner"})
print(prompt.to_string())Both produce the same text. The difference shows up when your application grows. The template version validates its inputs, composes with other LangChain components via the pipe operator, and separates prompt structure from data. If you have worked with LangChain's quickstart, you have already seen templates in passing. This tutorial goes deep.
ChatPromptTemplate — The Core Building Block
ChatPromptTemplate is the class you will use 90% of the time. You create one by calling from_messages() with a list of role-content tuples. LangChain scans every string for {variable_name} placeholders and automatically extracts the required input variables.
Input variables: ['language', 'question']When you call .invoke(), it requires exactly those keys. Pass fewer and you get a KeyError; pass extra and they are silently ignored. This strictness is a feature — it catches typos at runtime instead of letting a malformed prompt slip through to the model.
To turn the template into actual messages, pass a dictionary of variable values to .invoke(). The method returns a ChatPromptValue containing typed BaseMessage objects — each with a .type property ("system", "human", or "ai") and a .content string. This maps directly to the message format every LLM provider expects.
[system] You are an expert Python developer. Answer concisely.
[human] What is the difference between a list and a tuple?PromptTemplate vs ChatPromptTemplate
LangChain has two template families, and picking the wrong one is a common source of confusion. Here is the quick comparison:
from langchain_core.prompts import PromptTemplate
# Produces ONE string — no role structure
pt = PromptTemplate.from_template(
"Explain {topic} in simple terms."
)
print(pt.invoke({"topic": "recursion"}).text)
# → "Explain recursion in simple terms."from langchain_core.prompts import ChatPromptTemplate
# Produces a LIST of typed messages
ct = ChatPromptTemplate.from_messages([
("system", "You are a tutor."),
("human", "Explain {topic} in simple terms."),
])
result = ct.invoke({"topic": "recursion"})
print(result.messages)
# → [SystemMessage(...), HumanMessage(...)]PromptTemplate outputs a flat string. ChatPromptTemplate outputs a list of typed messages with roles. Modern LLM APIs (OpenAI, Anthropic, Google) all expect the message-list format. My recommendation: use ChatPromptTemplate for all new code. Reserve PromptTemplate for legacy pipelines or the rare case where you need a raw string.
Message Types and Role Tuples
You have already seen "system" and "human". The third role — "ai" — unlocks few-shot examples by embedding previous model responses directly into the template. The model sees a prior Q&A exchange and matches that style for the new question.
[system] You are a helpful coding assistant.
[human] What does len() do?
[ai] len() returns the number of items in a container.
[human] What does enumerate() do?This manual approach works when you have a handful of fixed examples. But once you need 5, 10, or 50 examples chosen dynamically based on the input, you want FewShotChatMessagePromptTemplate — which we cover later in this tutorial.
Create a function called build_translation_prompt that takes source_lang (str), target_lang (str), and text (str) as arguments.
The function should simulate what ChatPromptTemplate does: format and return the system message string "You are a professional translator from {source_lang} to {target_lang}.".
Since LangChain cannot run in the browser, use pure Python string formatting.
Partial Variables and Default Values
Sometimes you know one variable at deploy time but another only at call time. You might fix the output language when you configure the app but let users provide the question. LangChain's .partial() method handles this cleanly.
The code below creates a base template with two variables, then uses .partial() to bind output_language early. The returned object is a new template — the original stays unchanged — that now only requires question at invoke time. I use this constantly for multi-tenant apps where each tenant has different system instructions but the same prompt skeleton.
Always respond in English.You can also bind a callable instead of a static value. LangChain calls the function at invoke time, so the value stays fresh on every request. A classic use case is injecting today's date — the lambda below runs each time .invoke() is called, so the prompt always reflects the current date without any manual threading through your application code.
Running this on March 6 produces: Today is 2026-03-06. You are a scheduling assistant.
MessagesPlaceholder — Injecting Chat History
This is the feature that makes prompt templates genuinely powerful for conversational apps. If you have built a chatbot with memory, you have seen this in action. A MessagesPlaceholder reserves a slot in the template where a list of message objects gets injected at runtime.
Without it, you would need to manually concatenate history strings and track role labels yourself. With MessagesPlaceholder, you pass typed HumanMessage and AIMessage objects and LangChain inserts them in the right position between the system message and the new user input.
[system] You are a helpful assistant. Be concise.
[human] What is Python?
[ai] A high-level programming language.
[human] What is it used for?
[ai] Web dev, data science, AI, and scripting.
[human] Which area has the most job openings?The history messages slot in between the system message and the new user message — exactly where every LLM provider expects conversation context. The template stays the same whether the history has 0 or 50 messages.
Create a function called build_chat_messages that takes:
system_msg (str) — the system instructionhistory (list of tuples) — each tuple is (role, content)user_input (str) — the new user messageReturn a list of (role, content) tuples in this order: system message, then all history messages, then the new user message.
This simulates what ChatPromptTemplate with MessagesPlaceholder produces.
FewShotChatMessagePromptTemplate — Dynamic In-Context Learning
Hard-coding a couple of ("ai", "...") tuples is fine for a demo. In production, I usually need to pick examples dynamically — maybe the best 3 out of 50 — based on what the user actually asked. That is where FewShotChatMessagePromptTemplate comes in.
The idea: you define an example prompt that formats each individual example, then pass a list of examples. LangChain renders each example through the prompt and concatenates them into the final message list. Here is a sentiment classifier that uses three labelled examples to steer the model:
[system] Classify the sentiment as positive, negative, or neutral.
[human] I love this product!
[ai] positive
[human] Terrible experience.
[ai] negative
[human] It was okay, nothing special.
[ai] neutral
[human] The update broke everything.The model sees three labelled exchanges before the real question. It pattern-matches against those examples and responds with the same format. Changing the examples changes the model's behavior without touching the system instruction.
Dynamic Example Selection
When your example bank grows large, you do not want to stuff all of them into every prompt. LangChain provides example selectors that pick the most relevant examples on the fly. SemanticSimilarityExampleSelector uses embeddings to find examples closest to the user's input. LengthBasedExampleSelector picks examples until a token budget is reached.
When the user asks about shipping, the selector pulls the two examples most semantically similar to shipping — likely the shipping and the broken-product examples. The model gets targeted context instead of a generic sample. If you are building RAG pipelines or classification tools at scale, dynamic selection is essential.
Composing Templates — Piping into an LLM
A prompt template on its own just produces message objects. The real payoff comes when you pipe it into a model and an output parser using LCEL (LangChain Expression Language). LCEL uses the | pipe operator — borrowed from Unix — to connect steps into a chain. Data flows left to right: the template produces messages, the model generates a response, and StrOutputParser extracts the response text as a plain string.
The entire chain is itself a Runnable — you can call .invoke(), .stream(), or .batch() on it. Streaming is especially useful for chat interfaces where you want tokens to appear in real time:
Each chunk is a string fragment. The template does not interfere with streaming — it renders the prompt once, and the model handles the rest. Want to swap gpt-4o-mini for Claude or Gemini? The prompt template stays identical. You only change the model object. See our model switching guide for the full pattern.
Building a Reusable Prompt Library
In any serious LLM project, you end up with dozens of prompts for different tasks. Scattering definitions across endpoint handlers makes them hard to find and easy to accidentally duplicate. The pattern above centralizes everything in one module — every developer on the team imports from the same prompts.py.
Consuming the library is straightforward. Import the template, pipe it into a model, and invoke. Changing a system instruction means editing one file instead of hunting through handler code.
Real-World Example: Multi-Language Code Reviewer
Time to combine everything. We will build a code review assistant that accepts a programming language, a code snippet, and optional conversation history for follow-up questions. The template uses {language} in the system message to customize the reviewer's expertise, and MessagesPlaceholder with optional=True so the first review works without any history.
The first review passes an empty history. The model spots the division-by-zero risk and likely suggests a dictionary dispatch pattern:
For the follow-up, we construct history by wrapping the original exchange in HumanMessage and AIMessage objects. A new template is created specifically for follow-up questions — it drops the code review system instruction in favor of a simpler persona, since the model already has the code context from history. This lets us ask clarifying questions without repeating the entire code snippet.
The model has the full context of the original review. It generates the refactored version without needing the code repeated. For building full multi-turn chat memory beyond manual history lists, see the chatbot memory tutorial.
Common Mistakes and How to Fix Them
I keep a running list of template bugs I have debugged in my own code and in code reviews. These four come up over and over.
1. Forgetting to Escape Literal Braces
LangChain uses {variable} syntax for placeholders. If your prompt contains literal curly braces — JSON examples, dictionary literals — you must double them so LangChain treats them as literal characters instead of variables.
template = ChatPromptTemplate.from_messages([
("human", 'Return JSON like: {"name": "value"}'),
])
# KeyError: 'name'template = ChatPromptTemplate.from_messages([
("human", 'Return JSON like: {{"name": "value"}}'),
])
# Produces: Return JSON like: {"name": "value"}2. Passing a String to MessagesPlaceholder
The MessagesPlaceholder variable expects a list of message objects, not a raw string. Passing a string gives a confusing type error that does not mention the placeholder by name. Always wrap even a single message in a list.
3. Variable Name Typos
If your template uses {user_question} but you pass {"question": "..."}, LangChain raises a KeyError. Always check template.input_variables to see the exact keys it expects.
4. Putting MessagesPlaceholder in the Wrong Position
History should go between the system message and the current human message. Placing it after the human message means the model sees the new question before the conversation context — which confuses most models.
Create a function called make_partial_prompt that takes base_template (str with {style} and {topic} placeholders) and style (str).
Return a new function that accepts only topic (str) and returns the fully formatted string.
This simulates LangChain's .partial() method: binding one variable early and the other later.
Frequently Asked Questions
Can I load prompt templates from YAML or JSON files?
LangChain supports loading templates from files using load_prompt() from langchain_core.prompts. However, the YAML schema for ChatPromptTemplate can be brittle across versions. I prefer defining templates in Python modules — you get IDE autocompletion, type checking, and readable version control diffs.
How do I use Jinja2 syntax instead of f-string syntax?
Pass template_format="jinja2" when creating a PromptTemplate. This gives you {{ variable }} syntax plus conditionals and loops. Note that ChatPromptTemplate.from_messages() does not support Jinja2 directly — you would need to use PromptTemplate.from_template() for individual message strings that require Jinja2 features.
List 3 facts about Python.Is there a limit on how many messages a template can hold?
LangChain itself has no hard limit. The constraint comes from the model's context window. A template with few-shot examples and a MessagesPlaceholder can easily produce hundreds of messages. The template will not complain, but the model will reject anything beyond its token limit. Always pair your templates with a history-trimming strategy in production.
Do prompt templates work with non-OpenAI models?
Yes. Prompt templates are model-agnostic. ChatPromptTemplate produces BaseMessage objects that every LangChain chat model accepts — ChatOpenAI, ChatAnthropic, ChatGoogleGenerativeAI, ChatOllama, and others. Write the prompt once, pipe it into any model. Our model switching tutorial walks through the full provider-swapping pattern.
How do templates interact with output parsers?
Templates produce messages, parsers consume model output — they sit on opposite ends of the chain. But some parsers, like PydanticOutputParser, provide get_format_instructions() that you can inject into the template as a variable. This tells the model to respond in a specific JSON schema.
For a deeper dive into structured output, see the output parsers tutorial.