Hardcoding prompts as f-strings works until you have 30 variations across five models. Jinja2 gives you variables, conditionals, loops, and template inheritance – the same tools that power web frameworks, now applied to prompt engineering. Here’s a minimal example to get the idea:
1
2
3
4
5
| from jinja2 import Template
prompt = Template("Summarize this {{language}} code:\n```\n{{code}}\n```")
rendered = prompt.render(language="Python", code="print('hello')")
print(rendered)
|
Output:
1
| Summarize this Python code:
|
print(‘hello’)
That’s the core pattern. You define a template once, then render it with different data. The rest of this guide covers how to scale that into real prompt pipelines.
Basic Prompt Templates with Variables#
Start by loading templates from files instead of inline strings. This keeps your prompts version-controlled and separate from application logic.
Create a file called templates/classify.j2:
1
2
3
4
5
6
7
| You are a {{ role }}.
Classify the following text into one of these categories: {{ categories | join(", ") }}.
Text: {{ text }}
Category:
|
Now render and send it to OpenAI:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from jinja2 import Environment, FileSystemLoader
from openai import OpenAI
# Load templates from a directory
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("classify.j2")
# Render the prompt
prompt = template.render(
role="text classification expert",
categories=["positive", "negative", "neutral"],
text="The new update broke everything and I lost my data."
)
# Send to OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
)
print(response.choices[0].message.content)
|
The FileSystemLoader points to a directory. Every .j2 file in that directory becomes a template you can load by name. Jinja2’s built-in filters like join handle formatting so your Python code stays clean.
Conditionals and Loops for Dynamic Few-Shot Examples#
Few-shot prompts need examples, and the number of examples often varies based on task complexity or token budget. Jinja2 conditionals and loops handle this cleanly.
Create templates/few_shot.j2:
1
2
3
4
5
6
7
8
9
10
11
12
13
| {% if system_instruction %}{{ system_instruction }}
{% endif %}
{% if examples %}Here are some examples:
{% for ex in examples %}
Input: {{ ex.input }}
Output: {{ ex.output }}
{% endfor %}
{% endif %}
Now classify this:
Input: {{ input_text }}
Output:
|
This template adapts its shape based on what you pass in:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| from jinja2 import Environment, FileSystemLoader
from openai import OpenAI
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("few_shot.j2")
examples = [
{"input": "I love this product!", "output": "positive"},
{"input": "Terrible experience, never again.", "output": "negative"},
{"input": "It arrived on Tuesday.", "output": "neutral"},
]
# Full few-shot prompt
prompt_with_examples = template.render(
system_instruction="You are a sentiment classifier.",
examples=examples,
input_text="Best purchase I've made all year."
)
# Zero-shot prompt (same template, no examples)
prompt_zero_shot = template.render(
system_instruction="You are a sentiment classifier.",
examples=[],
input_text="Best purchase I've made all year."
)
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt_with_examples}],
)
print(response.choices[0].message.content)
|
One template handles both zero-shot and few-shot. You control behavior through data, not by maintaining separate prompt strings.
Template Inheritance for Prompt Chains#
Jinja2’s {% block %} and {% extends %} let you build layered prompt systems. Define a base structure, then override specific sections for different tasks.
Create templates/base_prompt.j2:
1
2
3
4
5
6
7
| You are a {{ role | default("helpful assistant") }}.
{% block task %}{% endblock %}
{% block format_instructions %}
Respond in plain text.
{% endblock %}
|
Create templates/summarize.j2:
1
2
3
4
5
6
7
8
9
10
11
12
| {% extends "base_prompt.j2" %}
{% block task %}
Summarize the following document in {{ num_sentences }} sentences.
Document:
{{ document }}
{% endblock %}
{% block format_instructions %}
Return only the summary, no preamble.
{% endblock %}
|
Create templates/extract.j2:
1
2
3
4
5
6
7
8
9
10
11
12
| {% extends "base_prompt.j2" %}
{% block task %}
Extract all named entities from this text and return them as a JSON array.
Text:
{{ text }}
{% endblock %}
{% block format_instructions %}
Return valid JSON only. Example: ["Entity1", "Entity2"]
{% endblock %}
|
Now build a two-step pipeline – summarize first, then extract entities from the summary:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| from jinja2 import Environment, FileSystemLoader
from openai import OpenAI
env = Environment(loader=FileSystemLoader("templates"))
client = OpenAI()
document = """
OpenAI announced GPT-4o in May 2024 at their Spring event in San Francisco.
The model supports text, vision, and audio inputs natively. Competitors
including Anthropic and Google DeepMind released similar multimodal models
throughout the year.
"""
# Step 1: Summarize
summarize_template = env.get_template("summarize.j2")
summary_prompt = summarize_template.render(
role="expert technical writer",
num_sentences=2,
document=document,
)
summary_response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": summary_prompt}],
)
summary = summary_response.choices[0].message.content
# Step 2: Extract entities from the summary
extract_template = env.get_template("extract.j2")
extract_prompt = extract_template.render(
role="named entity recognition specialist",
text=summary,
)
extract_response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": extract_prompt}],
)
entities = extract_response.choices[0].message.content
print(f"Summary: {summary}")
print(f"Entities: {entities}")
|
Each step uses a different child template that inherits the same base structure. Adding a third step means creating one more .j2 file and a few lines of Python – no prompt string surgery.
Building a Reusable Pipeline Runner#
Wrap the pattern into a function so you can chain arbitrary templates:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| from jinja2 import Environment, FileSystemLoader
from openai import OpenAI
env = Environment(loader=FileSystemLoader("templates"))
client = OpenAI()
def run_prompt(template_name: str, model: str = "gpt-4o", **kwargs) -> str:
"""Render a Jinja2 template and send it to OpenAI."""
template = env.get_template(template_name)
prompt = template.render(**kwargs)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
def run_pipeline(steps: list[dict]) -> dict[str, str]:
"""Run a sequence of prompt steps, passing outputs forward."""
results = {}
for step in steps:
template_name = step["template"]
kwargs = step.get("kwargs", {})
# Inject previous step outputs into kwargs
for key, ref in step.get("inputs_from", {}).items():
kwargs[key] = results[ref]
results[step["name"]] = run_prompt(template_name, **kwargs)
return results
# Define a pipeline
pipeline = [
{
"name": "summary",
"template": "summarize.j2",
"kwargs": {
"role": "technical writer",
"num_sentences": 2,
"document": "OpenAI released GPT-4o with multimodal capabilities...",
},
},
{
"name": "entities",
"template": "extract.j2",
"kwargs": {"role": "NER specialist"},
"inputs_from": {"text": "summary"},
},
]
results = run_pipeline(pipeline)
for name, output in results.items():
print(f"[{name}]: {output}\n")
|
The inputs_from mapping connects step outputs to the next step’s template variables. You define pipelines as data, not code.
Common Errors and Fixes#
jinja2.exceptions.UndefinedError: 'variable_name' is undefined
You forgot to pass a variable that the template expects. Either add it to your render() call or set a default in the template:
1
| {{ variable_name | default("fallback value") }}
|
Template not found: jinja2.exceptions.TemplateNotFound
The FileSystemLoader path is wrong. Use an absolute path or verify relative paths from where your script runs:
1
2
3
| import os
template_dir = os.path.join(os.path.dirname(__file__), "templates")
env = Environment(loader=FileSystemLoader(template_dir))
|
Whitespace and newlines messing up prompts
Jinja2 adds blank lines around block tags by default. Use trim_blocks and lstrip_blocks to clean it up:
1
2
3
4
5
| env = Environment(
loader=FileSystemLoader("templates"),
trim_blocks=True,
lstrip_blocks=True,
)
|
This strips the newline after block tags and leading whitespace before them. Your rendered prompts will be much cleaner.
Special characters in user input breaking templates
If user input contains {{ or {%, Jinja2 will try to parse it. Use the Markup class or render user input as a variable (which is auto-escaped in the output), never concatenate it into the template string directly. Since you’re passing user text through render(), this is handled automatically – the risk is only if you build template strings from user input at runtime.
Template inheritance not working
Make sure child templates start with {% extends "base.j2" %} as the very first line. Any content before the extends tag causes Jinja2 to silently ignore the inheritance.