Skip to main content

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.

Scope of a Handlebars to Jinja2 migration Template syntax, helpers and the render call change, while the compiled HTML, the inliner stage and the ESP integration are unaffected. What the Migration Actually Touches Changes tag syntax and block constructs helpers become filters or globals escaping must be turned on explicitly roughly a day per template family Stays identical the table markup you already ship the CSS inlining stage headers, ESP client and event handling no client-compatibility work is repeated
The migration is confined to the authoring layer, which is what makes a template-by-template rollout safe.

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 }} Helpers become filters (or globals)
{{{rawHtml}}} {{ raw_html | safe }} Triple-stache β†’ | safe filter
{{!-- 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.

{{!-- Handlebars: receipt.hbs --}}
{{> button label="View receipt" url=receiptUrl}}   {{!-- parameterized partial --}}
<table role="presentation" width="100%">
  {{#each items}}                                  {{!-- implicit `this` per item --}}
  <tr>
    <td>{{this.name}}</td>                          {{!-- escaped by default --}}
    <td align="right">{{formatCurrency this.cents}}</td>  {{!-- helper call --}}
  </tr>
  {{/each}}
</table>
<p>Charged on {{formatDate chargedAt}}.</p>
{# Jinja2: receipt.html #}
{% from "macros.html" import button %}             {# partial-with-params β†’ macro #}
{{ button("View receipt", receipt_url) }}
<table role="presentation" width="100%">
  {% for item in items %}                          {# explicit loop variable, no `this` #}
  <tr>
    <td>{{ item.name }}</td>                         {# escaped β€” autoescape=True is on #}
    <td align="right">{{ item.cents | format_currency }}</td>  {# helper β†’ filter #}
  </tr>
  {% endfor %}
</table>
<p>Charged on {{ chargedAt | format_date }}.</p>
{# Jinja2: macros.html β€” the parameterized Handlebars partial as a macro #}
{# Outlook 2016-2021 (Word engine) ignores padding on <a>; keep the table-cell button #}
{% macro button(label, url) -%}
<table role="presentation" cellpadding="0" cellspacing="0" align="center">
  <tr><td bgcolor="#A53860" style="border-radius:6px;">
    <a href="{{ url }}" style="display:inline-block;padding:14px 28px;color:#ffffff;
       font-family:Arial,sans-serif;font-size:16px;text-decoration:none;">{{ label }}</a>
  </td></tr>
</table>
{%- 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:

Whitespace left behind by block tags Without trim controls each loop iteration leaves a blank line that renders as a gap between table rows; trimming removes it. Blank Lines Become Real Gaps no trim controls line item line item a stripe of dead space per iteration trim_blocks and lstrip_blocks line item line item line item rows sit flush, as authored
Whitespace that is invisible in a web page is visible in an email table, which is why the trim settings are not optional here.
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.

Handlebars to Jinja2 concept mapping Each Handlebars construct on the left maps to a Jinja2 equivalent on the right, with autoescaping called out as the semantic difference. Handlebars β†’ Jinja2 Mapping Handlebars (Node) Jinja2 (Python) {{#each items}} {% for item in items %} {{#if cond}} {% if cond %} {{> header}} {% include / macro %} helper: {{fmt x}} filter: {{ x | fmt }} Semantic difference: escaping Handlebars escapes by default Β· Jinja2 needs autoescape=True or data ships raw
Constructs map almost one-to-one; the load-bearing difference is that Jinja2 must be told to autoescape, where Handlebars does it for you.

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.

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.

Validation Checklist

Frequently Asked Questions

Can the two engines run side by side during the migration?

Yes, and it is the sensible approach: route each template to whichever engine owns it and migrate one at a time. The rendered HTML is the interface between the template layer and everything downstream, so the inliner, the send path and the event handling are indifferent to which engine produced it.

What is the most common bug introduced by the migration?

Autoescaping being off. Handlebars escapes by default and Jinja2's bare Environment does not, so a straight syntax conversion produces templates that render user data unescaped. It is invisible until a value contains markup, at which point it is both a layout bug and an injection risk.

How do helpers with multiple arguments translate?

As filters with arguments, or as globals where the call reads better as a function. A Handlebars helper taking two positional arguments usually becomes a filter on the first with the second passed as a filter argument, which reads naturally; a helper taking three or more is usually clearer as a global function.

Do partials and includes behave the same?

Not quite. A Handlebars partial receives the current context automatically, and so does a Jinja2 include β€” but a Jinja2 macro does not, which is the closer equivalent for a parameterised partial. Converting a parameterised partial to an include silently loses its arguments, which is the second most common migration bug.

What about whitespace differences in the output?

Jinja2 leaves the newline after a block tag by default, which renders as extra space between table rows. Enabling trim_blocks and lstrip_blocks on the environment restores output close to what Handlebars produced, and is worth setting before converting rather than after, so the diffs stay readable.


← Back to Handlebars Email Templates