Use Prompt Templates
What a Prompt Template Is
A prompt template is a prompt with placeholders for the parts that change between requests. Your code fills in the placeholders with real values, such as a document, a user's question, or a product name, to produce the final prompt.
TEMPLATE = "Summarize the following article in {sentences} sentences:\n\n<article>\n{article}\n</article>"
prompt = TEMPLATE.format(sentences=3, article=article_text)
The template is written once. The data changes with every request.
Templates appeared briefly in earlier lessons (17, 22, 32). This lesson covers them properly: why they matter, the main ways to build them in Python, how to avoid common pitfalls such as brace conflicts and missing variables, and how to manage templates in a real project.
Why Use Templates
| Benefit | Explanation |
|---|---|
| Consistency | Every request uses the same tested wording, structure, and instructions |
| Separation | Prompt wording (owned by whoever designs prompts) is separate from code logic and from data |
| Maintainability | Improving the prompt means editing one template, not hunting through code |
| Testability | You can render a template with sample values and check the result before sending anything |
| Versioning | Templates can be stored, reviewed, and versioned like code (Lesson 32) |
| Safety | A fixed structure keeps user data inside designated tags, separate from instructions |
Approach 1: f-Strings
def review_prompt(code: str, language: str) -> str:
return f"""Review this {language} code for bugs and readability.
<code>
{code}
</code>
List each problem with a one-sentence fix."""
- Advantages: simple, readable, and checked by your editor. Variables must exist when the function runs.
- Limitations: the template lives inside code. It cannot be loaded from a file or edited without changing Python code.
f-strings are a good choice for small templates that are used in one place.
Approach 2: str.format() with Stored Templates
A template stored as a normal string, for example in a constant or a file, can be filled later with .format():
SUMMARY_TEMPLATE = """Summarize the following article in {sentences} sentences for {audience}.
<article>
{article}
</article>"""
prompt = SUMMARY_TEMPLATE.format(sentences=3, audience="busy managers", article=article_text)
The Brace Problem
.format() treats every {...} in the template as a placeholder. Templates that contain literal braces, such as JSON examples or code, break:
TEMPLATE = 'Return JSON like {"name": "...", "age": 0} for this person: {person}'
TEMPLATE.format(person="Asha, 29") # KeyError: '"name"'
Literal braces must be doubled:
TEMPLATE = 'Return JSON like {{"name": "...", "age": 0}} for this person: {person}'
This is easy to forget and makes templates with code or JSON hard to read.
Values Are Not Re-Parsed
Braces inside the values you pass are safe. .format() does not interpret the substituted text:
"<code>\n{code}\n</code>".format(code="data = {'a': 1}") # Works fine
The problem only arises when braces appear in the template text itself, or when you concatenate user input into a template before calling .format(), which turns user input into template text.
Approach 3: string.Template
Python's standard library includes string.Template, which uses $name placeholders instead of braces. Braces in the template are just ordinary characters, which makes it well suited to prompts that contain JSON or code:
from string import Template
EXTRACT_TEMPLATE = Template("""Extract the contact details from the text below.
Return JSON in exactly this shape: {"name": "...", "email": "...", "phone": "..."}
<text>
$text
</text>""")
prompt = EXTRACT_TEMPLATE.substitute(text="Call Ravi at 98765 43210 or ravi@example.com")
| Method | Behavior when a variable is missing |
|---|---|
substitute(...) | Raises KeyError, so a mistake is caught immediately |
safe_substitute(...) | Leaves the placeholder in the text, which is usually a hidden bug |
Prefer substitute() for prompts, so a missing value fails loudly instead of silently sending $text to Claude.
Use ${name} when a placeholder is directly followed by letters, as in ${count}th.
Approach 4: Jinja2 for Complex Templates
When templates need loops or conditions, for example to render a variable number of documents or optional sections, the popular Jinja2 library is a good fit:
python -m pip install jinja2
from jinja2 import Environment, StrictUndefined
env = Environment(undefined=StrictUndefined, autoescape=False)
QA_TEMPLATE = env.from_string("""<documents>
{% for doc in documents %}
<document index="{{ loop.index }}">
<source>{{ doc.source }}</source>
<document_content>{{ doc.content }}</document_content>
</document>
{% endfor %}
</documents>
{% if audience %}Write for {{ audience }}.{% endif %}
Answer using only the documents above.
<question>{{ question }}</question>""")
prompt = QA_TEMPLATE.render(
documents=[{"source": "faq.md", "content": "Sessions run Mondays at 7 PM IST."}],
question="When are the sessions?",
audience="new students",
)
Important settings:
undefined=StrictUndefinedmakes rendering fail with an error if a variable is missing. Jinja2's default is to silently render an empty string.autoescape=Falseis correct for prompts. Autoescaping is for HTML output and would turn characters such as<into<, damaging your XML-style tags.{% for %}and{% if %}handle variable numbers of documents and optional sections cleanly.
Comparison
| Approach | Placeholder | Braces in template | Loops/conditions | Missing variable | Best for |
|---|---|---|---|---|---|
| f-string | {name} | Must double them | Python code around it | NameError at runtime | Small inline prompts |
str.format() | {name} | Must double them | No | KeyError | Simple stored templates |
string.Template | $name | Fine as-is | No | KeyError with substitute() | Stored templates with JSON or code |
| Jinja2 | {{ name }} | Fine as-is | Yes | Error with StrictUndefined | Complex, structured templates |
Keep Data Inside Tags
Wherever possible, place each variable inside a clearly named tag rather than in the middle of an instruction:
Weak: Translate {text} into French.
Better: Translate the text inside <text> tags into French.
<text>
{text}
</text>
Why: if the inserted text is long, contains instructions, or contains unusual characters, the tags keep it clearly separate from your own instructions. Lesson 40 covers how this helps against prompt injection.
Warning: Never render templates supplied by users with a template engine. Template syntax can execute expressions, so user-provided templates are a security risk known as template injection. Users supply values; your application supplies templates.
Managing Templates in a Project
As an application grows, keep templates out of the code that uses them:
myapp/
├── prompts/
│ ├── summarize_v2.txt
│ ├── classify_ticket_v1.txt
│ └── extract_contact_v1.txt
└── prompting.py # Loads, validates, and renders templates
Good practices for template files:
- Name templates by purpose and version, so changes are deliberate and traceable.
- Document required variables at the top of the file, or validate them automatically.
- Test templates by rendering them with sample values before deployment.
- Log the template name and version with each request, alongside the message ID (Lesson 29).
Practical Example: A Validated Template Class
This class loads templates written in string.Template syntax, detects their required variables, validates inputs before rendering, and records which template version was used.
"""prompting.py: load, validate, and render versioned prompt templates."""
import re
from dataclasses import dataclass
from string import Template
from anthropic import Anthropic
from anthropic.types import Message
PLACEHOLDER = re.compile(r"\$(?:(\w+)|\{(\w+)\})")
@dataclass(frozen=True)
class PromptTemplate:
name: str
version: int
text: str
@property
def variables(self) -> set[str]:
return {a or b for a, b in PLACEHOLDER.findall(self.text)}
def render(self, **values: str) -> str:
missing = self.variables - values.keys()
extra = values.keys() - self.variables
if missing:
raise ValueError(f"{self.name} v{self.version}: missing variables {sorted(missing)}")
if extra:
raise ValueError(f"{self.name} v{self.version}: unexpected variables {sorted(extra)}")
return Template(self.text).substitute(**{k: str(v) for k, v in values.items()})
CLASSIFY_TICKET = PromptTemplate(
name="classify_ticket",
version=1,
text="""Classify the support ticket below for the $product team.
Reply with JSON exactly like {"category": "...", "urgent": true}, where category is one of:
billing, bug, account, other.
<ticket>
$ticket
</ticket>""",
)
def get_text(message: Message) -> str:
return "".join(block.text for block in message.content if block.type == "text").strip()
if __name__ == "__main__":
print("Variables:", sorted(CLASSIFY_TICKET.variables))
prompt = CLASSIFY_TICKET.render(
product="GoCourse",
ticket="I paid yesterday but my course still shows as locked!",
)
reply = Anthropic().messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
print(f"[{CLASSIFY_TICKET.name} v{CLASSIFY_TICKET.version}] {get_text(reply)}")
try:
CLASSIFY_TICKET.render(ticket="Missing product variable")
except ValueError as error:
print("Validation:", error)
Example output:
Variables: ['product', 'ticket']
[classify_ticket v1] {"category": "billing", "urgent": true}
Validation: classify_ticket v1: missing variables ['product']
How It Works
PLACEHOLDERis a regular expression that matches both$nameand${name}.findall()returns pairs of capture groups, anda or bpicks whichever group matched.variableslists the placeholders the template needs. It is computed from the text, so it is always in sync with the template.render()compares the provided values with the required variables. Missing and unexpected variables are both errors. An unexpected variable usually means a typo, such asticketsinstead ofticket.Template(...).substitute()fills in the values. The JSON example in the template contains braces, whichstring.Templateleaves untouched, so no doubling is needed.frozen=Trueprevents a template from being modified at runtime, andnameandversionmake every request traceable to the exact template that produced it.
Note: The JSON format here is requested through instructions only. Lesson 36 explains why that can occasionally fail, and how structured outputs guarantee valid JSON.
Common Mistakes
Unescaped Braces in .format() Templates
What happens: KeyError or ValueError when the template contains JSON or code.
Fix: double the braces, or use string.Template or Jinja2.
Concatenating User Input into the Template Before Formatting
template = "Summarize: " + user_text + " in {n} sentences"
template.format(n=3) # Breaks if user_text contains braces
Fix: pass user input as a value: "Summarize: {text} in {n} sentences".format(text=user_text, n=3).
Silently Missing Variables
What happens: safe_substitute() or Jinja2's default undefined handling sends prompts with empty or literal placeholders, and Claude receives an incomplete prompt.
Fix: use substitute(), StrictUndefined, or explicit validation.
Autoescaping Prompt Templates
What happens: Jinja2 with HTML autoescaping turns <document> into <document>.
Fix: set autoescape=False for prompt templates.
Rendering User-Supplied Templates
What happens: template injection, a security vulnerability.
Fix: only render templates written by your application. Treat user input strictly as values.
Best Practices
- Use templates for every prompt that runs more than once.
- Choose the simplest tool that fits: f-strings for inline prompts,
string.Templatefor stored prompts with JSON or code, and Jinja2 for loops and conditions. - Fail loudly on missing variables.
- Place each variable inside a named tag.
- Store templates in versioned files, and log their versions with requests.
- Render and inspect templates in tests before deploying changes.
- Never treat user input as template text.
Exercise
- Write a
str.format()template that asks for JSON output and includes an example JSON object. Make it work by escaping the braces correctly. - Convert the same template to
string.Template. Which version is easier to read? - Create a Jinja2 template that renders a list of 1–5 code files, each in a
<file path="...">tag, followed by a review instruction. Render it with two files. - Add a
preview(**values)method toPromptTemplatethat returns the rendered prompt with each value shortened to at most 40 characters, which is useful for logging.
Step 1:
TEMPLATE = 'Describe this person as JSON like {{"name": "...", "age": 0}}:\n<person>{person}</person>'
print(TEMPLATE.format(person="Asha, 29, data analyst"))
{{ and }} render as literal { and }.
Step 4:
def preview(self, **values: str) -> str:
short = {key: (str(value)[:37] + "...") if len(str(value)) > 40 else str(value)
for key, value in values.items()}
return self.render(**short)
Shortening values keeps logs readable and avoids storing long, possibly sensitive user text, while still showing how the template was filled.