Migrating from Handlebars to Jinja2 in Python Email Pipelines
Migrate transactional email templates from Node/Handlebars to Python/Jinja2: syntax mapping, autoescaping differences, whitespace control, and inliner integration.
A team running its email service on Node/Handlebars decides to consolidate on a Python stack, and the transactional templates have to come along. The work is mostly mechanical syntax translation, but two semantic differences — autoescaping behavior and whitespace handling — will silently break output if you copy templates verbatim. This deep-dive maps every Handlebars construct to its Jinja2 equivalent, shows a real template converted both ways, and wires the result into a Python render-and-inline path.
Why the Move Happens
The motivation is rarely about templating itself. A team running its product backend in Python (Django, FastAPI, Flask) ends up maintaining a separate Node service solely to render Handlebars email templates. Consolidating onto one runtime removes a deployment target, a second dependency tree, and the cross-language context-marshalling between the two. Jinja2 for Python apps is the natural destination because it covers the same ground — partials, helpers, conditionals, loops — with a richer expression syntax.
The catch is that Handlebars is logic-less and escapes HTML by default, while Jinja2 is expressive and, in its bare Environment, does not autoescape. Translate carelessly and you ship un-escaped user data into the inbox.
Exact Syntax Mapping
Most constructs map one-to-one. The table below is the reference; the semantic notes that follow it are where migrations actually fail.
| Handlebars | Jinja2 | Notes |
|---|---|---|
{{var}} |
{{ var }} |
Identical delimiters; Jinja2 conventionally spaces the inside |
{{user.name}} |
{{ user.name }} |
Dotted access works the same |
{{#each items}}…{{/each}} |
{% for item in items %}…{% endfor %} |
Jinja2 names the loop variable explicitly |
{{this}} (in each) |
{{ item }} |
No implicit this; use the named variable |
{{#if cond}}…{{else}}…{{/if}} |
{% if cond %}…{% else %}…{% endif %} |
Jinja2 allows full expressions in cond |
{{> header}} |
{% include "header.html" %} |
Include resolves a file via the loader |
{{> button label="X"}} |
{% from "macros.html" import button %}{{ button("X") }} |
Parameterized partials become macros |
{{formatCurrency cents}} |
`{{ cents | format_currency }}` |
{{{rawHtml}}} |
`{{ raw_html | safe }}` |
{{!-- comment --}} |
{# comment #} |
Both are stripped from output |
Autoescaping is the trap
Handlebars HTML-escapes {{ }} automatically. Jinja2's default Environment() does not — you must opt in. For HTML email, always construct the environment with autoescape enabled, or every user-supplied value becomes an XSS and layout-corruption risk.
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader("emails/templates"),
# CRITICAL: bare Environment() does NOT escape. Handlebars escaped by default;
# to preserve that behavior you MUST opt in here, or user data injects raw HTML.
autoescape=select_autoescape(["html", "xml"]),
)
With autoescaping on, {{ user.name }} escapes exactly like the Handlebars double-stache did, and {{ raw_html | safe }} becomes the deliberate equivalent of the triple-stache — used only for trusted, sanitized HTML.
Helpers become filters or globals
A Handlebars helper is a registered function. In Jinja2 the same function attaches as a filter (piped, value | name) or a global (called, name(value)). Filters read more naturally for formatting.
from babel.numbers import format_currency as babel_currency
from babel.dates import format_date
def format_currency(cents, currency="USD"):
# Server-side, exactly like the Handlebars helper — the email client has no Intl.
return babel_currency((cents or 0) / 100, currency, locale="en_US")
env.filters["format_currency"] = format_currency # {{ item.cents | format_currency }}
env.filters["format_date"] = lambda d: format_date(d, format="long", locale="en_US")
Handlebars block helpers (custom {{#gt a b}}) have no direct filter equivalent — replace them with native Jinja2 expressions, which can do {% if a > b %} inline since Jinja2 is not logic-less.
A Real Template Converted Both Ways
Here is the same receipt fragment in each engine, annotated inline so the mapping is explicit.
<table role="presentation" width="100%">
<tr>
<td></td>
<td align="right"></td>
</tr>
</table>
<p>Charged on .</p>
{# Jinja2: receipt.html #}
{% from "macros.html" import button %} {# partial-with-params → macro #}
{{ button("View receipt", receipt_url) }}
{% for item in items %} {# explicit loop variable, no `this` #}
{{ item.name }} {# escaped — autoescape=True is on #}
{{ item.cents | format_currency }} {# helper → filter #}
{% endfor %}
Charged on {{ chargedAt | format_date }}.
{# Jinja2: macros.html — the parameterized Handlebars partial as a macro #}
{# Outlook 2016-2021 (Word engine) ignores padding on ; keep the table-cell button #}
{% macro button(label, url) -%}
{{ label }}
{%- endmacro %}
Variant: Whitespace Control
Handlebars largely leaves whitespace alone, and Node teams often handle it with the trim_blocks-like behavior of their formatter. Jinja2 emits the newlines and indentation around {% %} tags by default, which bloats the HTML and can push you toward Gmail's 102KB clip limit. Two mechanisms fix this:
env = Environment(
loader=FileSystemLoader("emails/templates"),
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True, # remove the newline after a block tag
lstrip_blocks=True, # strip leading whitespace before a block tag
)
For per-tag control, the minus sign trims surrounding whitespace: {%- … -%}. The macro above uses {% macro … -%} and {%- endmacro %} so the table is not wrapped in stray blank lines. Apply {%- -%} inside loops to keep table rows from accumulating blank lines that Outlook can render as gaps.
Pipeline Integration
Keep the same inliner contract you had in Node: compile → inline → send. On the Python side that is a Jinja2 Environment feeding css_inline (or premailer), preserving media queries for iOS Mail and Apple Mail.
import css_inline
def render_email(template_name: str, context: dict) -> str:
html = env.get_template(template_name).render(**context) # interpolate first
# Inline AFTER render, same ordering as the Handlebars+Juice pipeline.
inliner = css_inline.CSSInliner(keep_style_tags=False)
return inliner.inline(html) # @media survives by default
This drops into a Celery or RQ worker exactly where the Node render call used to live; the upstream context payload is unchanged, only the renderer is swapped. See Jinja2 for Python apps for the async worker and provider-dispatch details.
Extended Construct Mapping
The first table covered the common cases. Real Handlebars templates also lean on unless, with, loop metadata, and helper subexpressions — and each has a clean Jinja2 form once you stop treating Jinja2 as logic-less.
| Handlebars | Jinja2 | Notes |
|---|---|---|
{{#unless cond}}…{{/unless}} |
{% if not cond %}…{% endif %} |
Negation is an inline expression in Jinja2 |
{{#with obj}}…{{/with}} |
{% with x = obj %}…{% endwith %} or {% set x = obj %} |
Scopes a sub-object; with block auto-unscopes |
{{@index}} / {{@first}} |
{{ loop.index0 }} / {{ loop.first }} |
loop.index is 1-based; loop.index0 is 0-based |
{{#each items}}…{{else}}…{{/each}} |
{% for i in items %}…{% else %}…{% endfor %} |
Jinja2 for…else runs the else on an empty list |
{{#if (eq a b)}} (subexpression) |
{% if a == b %} |
Helper-as-subexpression becomes a native operator |
{{lookup obj key}} |
{{ obj[key] }} |
Dynamic key access is plain subscripting |
{{a (b c)}} (nested helpers) |
`{{ c | b |
{{!-- block comment --}} |
{# block comment #} |
Both stripped; Jinja2 also has {% raw %} |
The loop object is the biggest upgrade: loop.last, loop.length, and loop.cycle('odd','even') replace the manual index tracking that Handlebars forces you to pass in through context. Auditing every {{@index}} during the move is worthwhile because the off-by-one between loop.index and loop.index0 is the most common silent regression.
Finer Escaping and Whitespace Control
Autoescaping is global once you set it on the Environment, but Jinja2 lets you turn it off for a region — useful when a block is entirely trusted pre-rendered HTML (such as a layout body slot you migrated from a triple-stache):
{# A trusted, server-generated fragment — disable escaping for this region only #}
{% autoescape false %}
{{ prerendered_body }} {# equivalent to the old Handlebars {{{body}}} triple-stache #}
{% endautoescape %}
For the inverse — forcing escaping on a value that arrived as Markup — use {{ value | e }} (alias of escape) or {{ value | forceescape }}, which escapes even already-Markup strings. When a migrated helper needs to return trusted HTML the way a Handlebars SafeString did, wrap the result in markupsafe.Markup so autoescaping leaves it alone:
from markupsafe import Markup, escape
def highlight(text):
# Markup is the Jinja2 SafeString: escape the dynamic part, mark the wrapper trusted.
return Markup("<strong>{}</strong>").format(escape(text))
env.filters["highlight"] = highlight # {{ user.tier | highlight }} stays escaped where it matters
Whitespace control is not cosmetic in email. With trim_blocks and lstrip_blocks off, every {% for %} and {% if %} leaves a newline and indentation behind, and a long receipt loop can add kilobytes of pure whitespace — enough to push a borderline message past Gmail's 102KB clip threshold, after which Gmail (web/app) hides the footer behind a "View entire message" link. Enable both options globally and apply {%- -%} inside hot loops so the rendered HTML stays compact for iOS Mail and small enough to clear the clip limit.
Validation Checklist
autoescape=select_autoescape(["html","xml"])set — escaping parity with Handlebars confirmed- Every
{{#each}}rewritten to{% for %}with a named loop variable (no implicitthis) - Parameterized partials converted to macros; plain partials to
{% include %} - All helpers re-registered as filters or globals; block helpers replaced with native
{% if %} {{{triple}}}audited and converted to| safeonly for trusted, sanitized HTMLtrim_blocks/lstrip_blocksenabled and{%- -%}applied where output bloat appears{{#with}}blocks rewritten to{% with %}/{% set %}with scope verified{{@index}}/{{@first}}audited and mapped toloop.index/loop.index0(off-by-one checked)- Helper subexpressions (
{{#if (eq a b)}}) rewritten as native operators ({% if a == b %}) - Helper-returned HTML wrapped in
markupsafe.Markup(SafeString parity) - Inliner runs after render with media queries preserved (verified in iOS Mail / Apple Mail)
- Rendered payload under 102KB to avoid Gmail clipping
Related
- Jinja2 for Python apps — the destination engine, with async workers and provider dispatch
- Inline CSS automation — keeping the same compile-inline-send contract after the swap
- MJML component architecture — a higher-level alternative if you are rethinking templates during the move
← Back to Handlebars Email Templates