A Handlebars template engine for composing LLM prompts, built on Pydantic.
Designed for AI/agent developers who build system prompts from dynamic data — few-shot examples, user-specific context, tool descriptions, and other content that shouldn't be hardcoded. Templates are validated against Pydantic models at compile time, catching typos and missing fields before any data is rendered.
The only runtime dependency is Pydantic (2.0+).
pip install pydantic-handlebarsRequires Python 3.10+ and Pydantic 2.0+.
from pydantic_handlebars import render
# Simple variable interpolation
print(render('Hello {{name}}!', {'name': 'World'}))
#> Hello World!
# Dot-notation paths
print(render('{{person.name}}', {'person': {'name': 'Alice'}}))
#> Alice
# Block helpers
print(render('{{#each items}}{{this}}{{#unless @last}} {{/unless}}{{/each}}', {'items': ['a', 'b', 'c']}))
#> a b c
# Conditionals
print(render('{{#if show}}yes{{else}}no{{/if}}', {'show': True}))
#> yesTemplates are a natural fit for building LLM prompts from structured data. This example builds a system prompt for a support agent — the guidelines and few-shot examples are data-driven, so they can be stored in a database, customized per customer, and iterated on without code changes:
from pydantic import BaseModel
from pydantic_handlebars import compile
class Turn(BaseModel):
role: str
content: str
class AgentConfig(BaseModel):
company: str
guidelines: list[str]
dos: list[Turn]
donts: list[Turn]
system_prompt = compile(
"""\
You are a support agent for {{company}}.
{{#each guidelines~}}
- {{this}}
{{/each}}
Follow the patterns shown in these examples:
<examples label="good">
{{#each dos~}}
<{{role}}>
{{content}}
</{{role}}>
{{/each~}}
</examples>
<examples label="bad — avoid these patterns">
{{#each donts~}}
<{{role}}>
{{content}}
</{{role}}>
{{/each~}}
</examples>\
""",
AgentConfig,
)
prompt = system_prompt.render(
AgentConfig(
company='Acme Corp',
guidelines=[
'Be concise and helpful',
'Link to docs when relevant',
"If unsure, escalate — don't guess",
],
dos=[
Turn(role='user', content='How do I reset my password?'),
Turn(
role='assistant',
content='Go to Settings → Security → Reset Password. '
"You'll get a confirmation email within 2 minutes.\n"
'Docs: https://docs.acme.com/password-reset',
),
],
donts=[
Turn(role='user', content='How do I reset my password?'),
Turn(
role='assistant',
content="I'm not sure, maybe check the settings page? "
'Let me know if you find it!',
),
],
)
)
print(prompt)
"""
You are a support agent for Acme Corp.
- Be concise and helpful
- Link to docs when relevant
- If unsure, escalate — don't guess
Follow the patterns shown in these examples:
<examples label="good">
<user>
How do I reset my password?
</user>
<assistant>
Go to Settings → Security → Reset Password. You'll get a confirmation email within 2 minutes.
Docs: https://docs.acme.com/password-reset
</assistant>
</examples>
<examples label="bad — avoid these patterns">
<user>
How do I reset my password?
</user>
<assistant>
I'm not sure, maybe check the settings page? Let me know if you find it!
</assistant>
</examples>
"""The template references company, guidelines, dos, donts, role, and content — all
validated against AgentConfig's schema at compile time. A typo like {{guidlines}} raises
TemplateSchemaError immediately:
from agent_config import AgentConfig
from pydantic_handlebars import TemplateSchemaError, compile
try:
compile('Hello {{guidlines}}!', AgentConfig) # typo!
except TemplateSchemaError as e:
print(e)
"""
1 error(s) found:
- guidlines: Field 'guidlines' not found in schema
"""For user-provided templates where you want to check for errors without raising, use
check_template_compatibility instead — it returns a result object with is_compatible and
issues:
from agent_config import AgentConfig
from pydantic import TypeAdapter
from pydantic_handlebars import check_template_compatibility
schema = TypeAdapter(AgentConfig).json_schema(mode='serialization')
result = check_template_compatibility('Hello {{guidlines}}!', schema)
print(result.is_compatible)
#> False
print(result.issues)
"""
[
TemplateIssue(
severity='error', message="Field 'guidlines' not found in schema", field_path='guidlines', template_index=0
)
]
"""Templates can be validated against a data type at compile time, catching typos, missing fields, and structural mismatches before any data is rendered.
Pass a Pydantic model (or any type supported by
TypeAdapter) as the second argument to
compile. The template is validated against the model's JSON schema at compile time:
from pydantic import BaseModel
from pydantic_handlebars import compile
class User(BaseModel):
name: str
age: int
# Validates template fields against User's schema
template = compile('Hello {{name}}, age {{age}}!', User)
# Render with typed data — serialization handled automatically
print(template.render(User(name='Alice', age=30)))
#> Hello Alice, age 30!If the template references a field that doesn't exist in the schema, compilation fails:
from pydantic import BaseModel
from pydantic_handlebars import TemplateSchemaError, compile
class User(BaseModel):
name: str
age: int
try:
compile('Hello {{username}}!', User) # 'username' not in User
except TemplateSchemaError as e:
print(e)
"""
1 error(s) found:
- username: Field 'username' not found in schema
"""When compiling multiple templates against the same type, TypedCompiler caches the type adapter and JSON schema:
from pydantic import BaseModel
from pydantic_handlebars import typed_compiler
class User(BaseModel):
name: str
age: int
compiler = typed_compiler(User)
greeting = compiler.compile('Hello {{name}}!')
info = compiler.compile('{{name}} is {{age}} years old.')
user = User(name='Alice', age=30)
print(greeting.render(user))
#> Hello Alice!
print(info.render(user))
#> Alice is 30 years old.Typed templates offer two rendering modes:
from pydantic import BaseModel
from pydantic_handlebars import compile
class User(BaseModel):
name: str
age: int
template = compile('Hello {{name}}!', User)
# .render() — expects an already-validated instance of the type
print(template.render(User(name='Alice', age=30)))
#> Hello Alice!
# validate_and_render — validates input through Pydantic first,
# useful for unvalidated data (e.g., from a JSON API)
print(template.validate_and_render({'name': 'Bob', 'age': 25}))
#> Hello Bob!By default, typed templates only serialize the fields declared on the type you compile against — extra
fields from subclasses are stripped. This is the safer default since a subclass might add sensitive
fields (e.g. password). To include subclass fields, pass serialize_as_any=True:
from pydantic import BaseModel
from pydantic_handlebars import typed_compiler
class Animal(BaseModel):
name: str
class Dog(Animal):
breed: str
# serialize_as_any=True: subclass fields are preserved
compiler = typed_compiler(Animal, serialize_as_any=True)
template = compiler.compile('{{name}} ({{breed}})', raise_on_error=False)
print(template.render(Dog(name='Rex', breed='Labrador')))
#> Rex (Labrador)The same option is available on the compile() shortcut via
compile(source, tp, serialize_as_any=True).
For lower-level control, use check_template_compatibility directly with a JSON schema:
from pydantic_handlebars import check_template_compatibility
schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'items': {
'type': 'array',
'items': {
'type': 'object',
'properties': {'label': {'type': 'string'}},
},
},
},
'required': ['name', 'items'],
}
# Valid template — all referenced fields exist
result = check_template_compatibility('{{name}}: {{#each items}}{{label}} {{/each}}', schema)
print(result.is_compatible)
#> True
# Invalid template — 'missing_field' not in schema
result = check_template_compatibility('{{missing_field}}', schema)
print(result.is_compatible)
#> False
print(result.issues[0].field_path)
#> missing_fieldYou can also check multiple templates at once, which is useful for systems that pair a system prompt with a user prompt template:
from pydantic_handlebars import check_template_compatibility
schema = {
'type': 'object',
'properties': {'name': {'type': 'string'}},
'required': ['name'],
}
result = check_template_compatibility(
['System: you are helping {{name}}', 'User said: {{name}} wants help'],
schema,
)
print(result.is_compatible)
#> TrueThe schema checker statically walks the template AST and validates:
- Field existence —
{{name}}errors ifnameis not in the schema'sproperties(unlessadditionalPropertiesallows it) - Nested path walking —
{{user.address.city}}is validated through each level of the schema hierarchy #eachtargets — Verifies the target resolves to an array or object type; descends into theitemsschema for the loop body#withtargets — Verifies the target resolves to an object and validates the body against it#if/#unlessconditions — Validates the condition field exists in the schema@rootpaths —{{@root.name}}is validated against the root schema$refresolution — Follows$refpointers including Pydantic's$defs- Nullable types — Unwraps
anyOf: [{...}, {type: 'null'}]patterns fromOptional[X] - Union types — Checks field access across
anyOf/oneOfvariants defaulthelper —{{default field "fallback"}}relaxes checking for its first argument, since the fallback guards against missing values- Custom helpers — Arguments to registered helpers are validated; the helper body is treated as opaque (the checker can't know what context a custom helper provides)
In the age of agent development, templates are everywhere — system prompts, user-facing messages, tool descriptions. But templates have traditionally had a weakness: no type checking. A typo in a field name silently renders an empty string rather than failing loudly. This library captures more of the confidence you get from using Pydantic for data validation, applied to the template layer.
Because Handlebars is a widely adopted standard, LLMs are already familiar with its syntax — making it a natural choice when AI agents need to read, write, or modify templates.
Jinja2 is the most popular Python template engine, but its own documentation for the sandbox states: "The sandbox alone is not a solution for perfect security." The sandbox docs further warn about error handling, resource exhaustion, and data exposure risks. Jinja2 is also one of the most commonly exploited template engines for Server-Side Template Injection (SSTI) — because Jinja2 expressions can evaluate arbitrary Python, an attacker who controls template content can achieve remote code execution.
Handlebars has no code evaluation path by design. There is no mechanism in the Handlebars syntax to call arbitrary functions, access Python internals, or traverse object hierarchies beyond simple property access. The attack surface for template injection does not exist at the language level.
Python 3.14 introduces template strings (PEP 750) — a new
t"..." prefix that captures interpolation structure instead of immediately evaluating. While useful
for developer-authored strings in source code, t-strings are compile-time source-code literals.
You cannot load a template from a database, accept one from user input, or read one from a
configuration file and use it as a t-string.
The PEP itself acknowledges this limitation: "Projects such as Jinja are still needed in cases where the template is less part of the software by the developers, and more part of customization by designers or even content created by users."
pydantic-handlebars is designed for exactly this case — dynamic templates from any source, validated against a known schema.
Mustache is the logic-less subset of Handlebars. All Mustache templates are valid Handlebars.
However, Mustache lacks #if/#else, #unless, block parameters, and custom helpers — features
that are important for real-world prompt construction (conditional sections, iteration metadata,
domain-specific formatting).
Python f-strings and str.format() support only flat variable substitution — no dot-notation
paths, no loops, no conditionals, no custom logic. They also execute in the caller's scope, making
them unsuitable for untrusted templates.
Because the Handlebars grammar is constrained, templates can be parsed and analyzed without execution. This is what makes the schema validation feature possible — you can know at compile time whether a template is compatible with your data type. Template expressivity is in tension with static analysis, and Handlebars strikes a good balance between the two.
Handlebars (or its Mustache subset) is widely used across the AI/agent ecosystem — in agent frameworks, LLM observability platforms, prompt management tools, and API playgrounds. Its familiarity and simplicity make it a common default when these tools need a template language for prompt construction.
We use JSON Schema — rather than Python type reflection — as the validation medium. This makes the
checking portable: the same schema can validate templates regardless of what language the type was
originally defined in. A schema generated from a Rust struct, TypeScript interface, or Go struct
works identically. Pydantic models generate JSON schemas automatically via model_json_schema(), so
the integration is seamless.
All context data is automatically serialized to JSON-safe types before rendering — the template
engine only ever sees dicts, lists, strings, numbers, bools, and None. JSON Schema naturally
describes this serialized form. The typed template API (compile(source, Type)) handles
serialization via Pydantic's dump_python(mode='json').
- Variables and paths:
{{name}},{{person.name}},{{../parent}} - Blocks:
{{#if}},{{#unless}},{{#each}},{{#with}} - Comments:
{{! comment }},{{!-- long comment --}} - Literals: strings, numbers, booleans, null
- Subexpressions:
{{helper (other arg)}} - Hash arguments:
{{helper key=value}} - Raw blocks:
{{{{raw}}}}...{{{{/raw}}}} - HTML escaping (opt-in):
{{escaped}}vs{{{unescaped}}} - Whitespace control:
{{~expression~}} @datavariables:@root,@index,@key,@first,@last- Block parameters:
{{#each items as |item index|}} - Chained else:
{{else if condition}}
| Category | Helpers | Availability |
|---|---|---|
| Block | if, unless, each, with |
Standard |
| Utility | lookup, log |
Standard |
| String | json, uppercase, lowercase, trim, join, truncate, default |
Extra |
| Comparison | eq, ne, gt, gte, lt, lte |
Extra |
| Boolean | and, or, not |
Extra |
Standard helpers are always available. Extra helpers require HandlebarsEnvironment(extra_helpers=True).
Register helpers on a HandlebarsEnvironment:
from pydantic_handlebars import HandlebarsEnvironment, HelperOptions
env = HandlebarsEnvironment()
@env.helper
def shout(*args: object, options: HelperOptions) -> str:
return str(args[0]).upper() + '!!!'
print(env.render('{{shout name}}', {'name': 'world'}))
#> WORLD!!!You can also register with a custom name:
from pydantic_handlebars import HandlebarsEnvironment, HelperOptions
env = HandlebarsEnvironment()
@env.helper('loud')
def make_loud(*args: object, options: HelperOptions) -> str:
return str(args[0]).upper()
print(env.render('{{loud name}}', {'name': 'world'}))
#> WORLDTo use custom helpers with typed templates, pass the environment to typed_compiler:
from pydantic import BaseModel
from pydantic_handlebars import HandlebarsEnvironment, HelperOptions, typed_compiler
env = HandlebarsEnvironment()
@env.helper
def shout(*args: object, options: HelperOptions) -> str:
return str(args[0]).upper() + '!!!'
class User(BaseModel):
name: str
compiler = typed_compiler(User, env=env)
template = compiler.compile('{{shout name}}')
print(template.render(User(name='world')))
#> WORLD!!!HTML escaping is disabled by default (auto_escape=False). This library is designed for prompt
generation and other non-HTML use cases where escaping <, >, &, etc. would corrupt the output.
Warning: If you use this library to render HTML that will be displayed in a browser, you must enable
auto_escape=Trueto prevent cross-site scripting (XSS) vulnerabilities.
To enable escaping, create an environment with auto_escape=True:
from pydantic_handlebars import HandlebarsEnvironment
env = HandlebarsEnvironment(auto_escape=True)
print(env.render('{{content}}', {'content': '<b>bold</b>'}))
#> <b>bold</b>Triple-stache {{{expression}}} always outputs unescaped content regardless of the auto_escape
setting.
The optional_field_severity parameter controls how the schema checker handles references to fields
not explicitly listed in the schema's properties. By default, these are errors:
from pydantic_handlebars import check_template_compatibility
schema = {
'type': 'object',
'properties': {'name': {'type': 'string'}},
'required': ['name'],
}
# Default: unlisted fields are errors
result = check_template_compatibility('{{nickname}}', schema)
print(result.is_compatible)
#> False
# Lenient: unlisted fields are warnings (don't block compatibility)
result = check_template_compatibility('{{nickname}}', schema, optional_field_severity='warning')
print(result.is_compatible)
#> True
print(result.issues[0].severity)
#> warningThe default helper automatically relaxes this for its arguments, since the fallback value
guards against the field being missing:
from pydantic_handlebars import check_template_compatibility
schema = {
'type': 'object',
'properties': {'name': {'type': 'string'}},
'required': ['name'],
}
# 'nickname' is not in the schema, but default provides a fallback
result = check_template_compatibility(
'{{default nickname "Anonymous"}}', schema, helpers={'default'}
)
print(result.is_compatible)
#> Truepydantic-handlebars is designed to safely render untrusted templates:
- No code evaluation — The Handlebars expression language cannot call arbitrary Python functions, import modules, or execute code. Only explicitly registered helpers can be invoked.
- Data serialization — All context data is serialized to JSON-safe types via
pydantic_core.to_jsonable_pythonbefore rendering. The template engine only ever sees dicts, lists, strings, numbers, bools, and None — there are no dangerous Python objects to exploit, no dunders, no methods, no properties with side effects. - Depth limits — Template nesting is capped (default 100 levels) to prevent stack overflow from recursive or deeply nested templates.
- Output size limits — Rendered output is capped (default 10MB) to prevent memory exhaustion from template expansion attacks.
- Register only trusted helpers — Custom helpers execute as Python code. Only register helpers you control and trust.
- Validate templates against schemas — Use the typed compilation API or
check_template_compatibilityto catch field reference errors before rendering, especially when templates come from external sources.
If any of these features would be useful to you, please open an issue and let us know.
- Strict mode — A
strict: booloption that throwsHandlebarsRuntimeErroron missing variables instead of silently rendering empty strings. Complements compile-time schema validation with runtime enforcement. - Partials —
{{> partialName}}for template composition. Out-of-line partials registered on the environment (schema-opaque at check time), and inline partials ({{#*inline "name"}}...{{/inline}}) that are schema-checked since their body is visible. Anallow_partialsflag to optionally disallow partial syntax. - Error messages with source context — Show the relevant template snippet with a caret pointing to the problematic expression. Most of the infrastructure (token positions, source access) is already in place.
- Non-primitive rendering detection — Warning when
{{expression}}would render a complex object (dict, nested model) rather than a primitive. - Conditional type checking — Warning when
#ifis used on a field whose type is guaranteed truthy.
MIT