Prompt Templates & Variables
Prompt Templates and Variable Substitution
Lesson 2 covered building instructions with named function parameters when variation is limited to a handful of flags. Many real prompts need more than that: a support email generator that inserts a customer's name, order number, and issue description; a code review prompt that inserts a diff and a list of style rules; a report generator that inserts several paragraphs of retrieved data. Once a prompt has more than two or three variable slots, ad hoc f-strings become error-prone, and a proper templating approach pays for itself. This lesson covers string.Template, f-string-based helpers, and the tradeoffs between them.
Why Not Just Use an f-String?
f-strings are the default tool for string interpolation in Python, and for a prompt with one or two variables, they are entirely sufficient — nothing in this lesson forbids f"Summarize this: {text}" for a quick script. The trouble starts at scale, for reasons specific to how f-strings work:
# Fine for a one-off script
prompt = f"Translate the following text to {target_language}: {text}"
# Fragile once a template needs to live in a data file, be edited by
# non-engineers, or be validated before use
template_str = "Translate the following text to {target_language}: {text}"
prompt = template_str.format(target_language="French", text=user_text)
The second version uses .format(), which introduces the actual problem: .format() (and, by extension, any approach that treats the template as data rather than as literal Python source) has no compile-time check that all placeholders were supplied, and it treats any { or } character in the supplied values or in the template text itself as syntactically significant. If user_text happens to contain a literal brace — a JSON snippet a customer pasted, a code sample, a set literal — .format() raises a KeyError or produces mangled output, because it tries to interpret those braces as more placeholders.
f-strings themselves (the literal f"..." syntax) do not have this brace problem because they are evaluated by the Python parser at the point they're written, with variables already in scope — but that is exactly why they don't work for templates that need to be defined once, stored separately from the code that fills them in, or edited without touching Python source. A prompt template is data you want to keep separate from the calling code, version, and potentially load from a file or a database — not an in-place expression.
string.Template: A Safer Default for Data-Like Templates
Python's standard library string.Template class solves the brace problem directly, because it uses a different placeholder syntax ($name or ${name}) that does not collide with literal braces in the template or in supplied values:
from string import Template
REVIEW_REQUEST_TEMPLATE = Template("""You are a code reviewer. Review the following diff
for the file $filename.
Style rules to enforce:
$style_rules
Diff:
$diff
""")
def build_review_prompt(filename: str, style_rules: str, diff: str) -> str:
return REVIEW_REQUEST_TEMPLATE.substitute(
filename=filename,
style_rules=style_rules,
diff=diff,
)
diff_text = """
- def add(a, b):
- return a+b
+ def add(a, b):
+ return a + b
"""
prompt = build_review_prompt(
filename="math_utils.py",
style_rules="- Use spaces around binary operators.\n- Prefer f-strings over string concatenation.",
diff=diff_text,
)
print(prompt)
Notice that diff_text can contain any number of literal {, }, or even $$ (an escaped dollar sign, if needed) without breaking substitution, because Template.substitute only looks for the $identifier pattern, not braces. This makes string.Template a better fit than .format() specifically when the values being substituted are not fully controlled by the developer — user-submitted diffs, retrieved documents, pasted logs — which is exactly the kind of content that shows up in real prompt-building code.
Template also provides safe_substitute, which leaves unmatched placeholders in the output instead of raising, useful when a template has optional slots that are not always supplied:
optional_template = Template("Customer: $name\nNote: $note")
result = optional_template.safe_substitute(name="Priya Shah")
print(result)
# Customer: Priya Shah
# Note: $note
Prefer substitute (which raises KeyError for a missing variable) in most application code, because a silently unfilled placeholder reaching a live prompt — and therefore reaching the model — is usually a bug you want to catch immediately, not paper over. Reach for safe_substitute only when missing values are an expected, handled case, and even then, consider providing an explicit empty-string or default value in the substitution dictionary as a clearer alternative.
Building a Reusable Prompt Template Helper
For an application with many templates, wrapping string.Template in a small dataclass gives templates a name, a defined set of required variables, and validation, instead of scattering bare Template(...) calls:
from dataclasses import dataclass
from string import Template
@dataclass(frozen=True)
class PromptTemplate:
name: str
template: Template
required_vars: frozenset[str]
@classmethod
def from_string(cls, name: str, text: str, required_vars: set[str]) -> "PromptTemplate":
return cls(name=name, template=Template(text), required_vars=frozenset(required_vars))
def render(self, **kwargs) -> str:
missing = self.required_vars - kwargs.keys()
if missing:
raise ValueError(f"Template '{self.name}' missing variables: {sorted(missing)}")
return self.template.substitute(**kwargs)
review_template = PromptTemplate.from_string(
name="code_review",
text="Review the diff for $filename.\n\nDiff:\n$diff",
required_vars={"filename", "diff"},
)
prompt = review_template.render(filename="math_utils.py", diff=diff_text)
The required_vars set turns a missing-variable bug from a runtime KeyError deep inside string.Template into an explicit, application-level ValueError with the template's name and the exact missing keys — considerably easier to debug when templates are being assembled dynamically from several sources. This also gives you a single object (PromptTemplate) that can be logged, tested, and stored in a registry keyed by name, which becomes useful once prompt versioning (Lesson 8) needs to track which named template produced a given output.
Using a Template With the SDK
Rendering a template produces a plain string, which then flows into instructions or input exactly as in the previous lessons — templating is purely a string-construction step that happens before any API call:
from openai import OpenAI
client = OpenAI()
SUMMARY_TEMPLATE = PromptTemplate.from_string(
name="doc_summary",
text=(
"Summarize the following document in $max_sentences sentences or fewer. "
"Focus on $focus_area.\n\nDocument:\n$document"
),
required_vars={"max_sentences", "focus_area", "document"},
)
def summarize_document(document: str, focus_area: str, max_sentences: int = 3) -> str:
prompt = SUMMARY_TEMPLATE.render(
max_sentences=max_sentences,
focus_area=focus_area,
document=document,
)
response = client.responses.create(
model="gpt-5.6-terra",
input=prompt,
)
return response.output_text
Nothing about client.responses.create changes because a template was used to build the string — this is the important design property of templating done correctly. The model never knows or cares whether its input string was built by an f-string, a Template, or typed by hand; templating is entirely an application-side concern for keeping prompt construction maintainable, not a feature the API is aware of.
Jinja2 and When Templating Needs to Go Further
string.Template covers straightforward variable substitution well, but it has no support for conditionals, loops, or filters. When a template genuinely needs those — for example, rendering a variable-length list of few-shot examples (Lesson 4) or conditionally including a section only when a flag is set — a full templating engine such as Jinja2 is a reasonable upgrade:
from jinja2 import Template as JinjaTemplate
jinja_template = JinjaTemplate("""Classify the ticket below into one category.
{% if examples %}
Examples:
{% for ex in examples %}
- Text: "{{ ex.text }}" -> Category: {{ ex.category }}
{% endfor %}
{% endif %}
Ticket: {{ ticket_text }}
""")
rendered = jinja_template.render(
ticket_text="My package arrived damaged.",
examples=[
{"text": "I was charged twice.", "category": "billing"},
{"text": "The app crashes on login.", "category": "technical"},
],
)
The tradeoff is an external dependency and a more complex template syntax than $variable. For most applications, start with string.Template (or the PromptTemplate wrapper above) and reach for Jinja2 only once conditionals or loops inside the template itself are genuinely needed — introducing a templating engine's full control-flow syntax for a prompt that only ever substitutes three flat variables adds complexity without a matching benefit.
Testing Template Rendering
Template rendering is pure string manipulation, so it is fully testable without any API access:
def test_review_template_includes_all_fields():
prompt = review_template.render(filename="app.py", diff="- old\n+ new")
assert "app.py" in prompt
assert "- old\n+ new" in prompt
print("PASS: review template includes filename and diff")
def test_missing_variable_raises():
try:
review_template.render(filename="app.py")
raise AssertionError("expected ValueError for missing 'diff'")
except ValueError as e:
assert "diff" in str(e)
print("PASS: missing required variable raises ValueError naming it")
test_review_template_includes_all_fields()
test_missing_variable_raises()
These tests catch two very different bug classes cheaply: a template whose rendered output silently drops a variable (a broken template string), and a caller that forgot to supply a required value (a broken call site). Both are common sources of production incidents — the model receiving a prompt with an unintended gap in it — and both are caught here without spending any API quota.
Common Mistakes
Using .format() or bare f-strings for templates that hold untrusted or code-like content. Curly braces in a pasted JSON blob, a code diff, or a regular expression will break .format()-based substitution. Use string.Template's $variable syntax whenever the substituted values are not fully controlled by the developer.
Not validating that all required variables were supplied before rendering. A raw Template.substitute() call raises a KeyError with only the missing name, buried in whatever code path triggered it. Wrapping templates with explicit required_vars checks, as shown above, gives far more actionable error messages.
Reaching for a full templating engine before it's needed. Introducing Jinja2 (or similar) for prompts that only need flat variable substitution adds a dependency and syntax overhead without benefit. Start with string.Template and upgrade only when conditionals or loops are genuinely required inside the template.
Best Practices
Store templates as named, versionable objects, not inline strings scattered across call sites. A PromptTemplate (or equivalent) registered by name gives you one place to find, test, and later version each template.
Prefer $variable syntax over .format()-style braces for any template that will hold user-supplied or code-like content. This avoids an entire class of interpolation bugs caused by literal braces in the substituted data.
Write unit tests for template rendering, independent of any model call. Assert that rendered output contains expected substituted values and that missing required variables raise clear, named errors — this is fast, free, and catches real bugs.