Prompt Templates & Variables

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read ·Lesson 147 of 224

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.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Prompt Templates & Variables and get answers drawn from it.

Signed-in readers only.