Jinja2 Migration
NLT includes a bidirectional converter for migrating templates between Jinja2 and NLT syntax. This guide covers the syntax mapping, the programmatic converter API, and features unique to each language.
Syntax Mapping
The following table maps common Jinja2 patterns to their NLT equivalents:
| Jinja2 | NLT Equivalent |
|---|---|
{{ UserAttribute['First Name'] }} | {the user's First Name} |
{{ EventAttribute['trigger.Field'] }} | {the Field from the trigger event} |
{{ EventAttribute['payment.Amount'] }} | {the Amount from the payment event} |
{{ attr | default('Guest') }} | {the user's Name, or "Guest"} |
{{ attr | default('MOE_NOT_SEND') }} | {the user's Field, required} |
{% if condition %} | {if condition} |
{% elif condition %} | {else if condition} |
{% else %} | {else} |
{% endif %} | {end if} |
{% for item in list %} | {for item in the user's List} |
{% endfor %} | {end for} |
{{ value | upper }} | {the user's Value, uppercase} |
{{ value | lower }} | {the user's Value, lowercase} |
{{ value | title }} | {the user's Value, titlecase} |
{{ value | truncate(100) }} | {the user's Value, truncate 100} |
{{ value | replace('a', 'b') }} | {the user's Value, replace "a" with "b"} |
{{ value | urlencode }} | {the user's Value, url_encode} |
{{ value | join(', ') }} | {the user's Value, join ", "} |
{{ value | first }} | {the user's Value, first} |
{{ value | last }} | {the user's Value, last} |
{{ value | length }} | {the user's Value, length} |
{{ value | sort }} | {the user's Value, sort} |
{{ value | round(2) }} | {the user's Value, round 2} |
{{ value | int }} | {the user's Value, round 0} |
{{ loop.index }} | {loop.index} |
{{ loop.cycle('odd', 'even') }} | {loop.cycle "odd", "even"} |
{{ item.property }} | {item.property} |
{% set x = expr %} | {set x to expr} |
{# comment #} | {# comment #} (identical) |
{% raw %}...{% endraw %} | {raw}...{end raw} |
{{ 3 + 2 }} | {literal 3 plus 2} |
{{ "hello" | upper }} | {literal "hello", uppercase} |
{% include "partial.html" %} | {include "partial.html"} |
{% macro name(a, b='default') %} | {define name(a, b = "default")} |
{% for i in range(5) %} | {for i in range 5} |
{% for i in range(1, 11) %} | {for i in range 1 to 10} |
{%- ... -%} | {-...-} |
{{ super() }} | {parent} |
{% block name %}...{% endblock %} | {block name}...{end block} |
Key Syntax Differences
- Single braces, not double. NLT uses
{ }for everything. No{{ }}or{% %}distinction. - No pipe operator. NLT uses comma-separated modifiers instead of
|pipes. - Natural language source phrases.
the user's Xinstead ofUserAttribute['X']. - Built-in send suppression.
requiredreplacesdefault('MOE_NOT_SEND')with proper suppression tracking. - Built-in display blocks.
{list}and{table}generate styled HTML directly -- no loop boilerplate needed. - Whitespace control. NLT uses
{-...-}to trim surrounding whitespace, equivalent to Jinja2's{%- -%}. - Literal expressions. NLT supports math and filters on literal values via
{literal ...}, replacing Jinja2's{{ 3 + 2 }}and{{ "hello" | upper }}. - No arbitrary code execution. NLT has no global function calls or mutation operations.
Programmatic Converter
The converter module provides four functions for automated template migration:
Jinja2 to NLT
NLT to Jinja2
Detect Template Language
Use this to determine whether a template needs conversion before processing.
Count Expressions
Use detect_template_language() in your migration pipeline to skip templates that are already in the target format, and to flag "mixed" templates that need manual review.
NLT-Only Features
These NLT features have no direct Jinja2 equivalent:
| Feature | NLT Syntax | Jinja2 Alternative |
|---|---|---|
| Quick lists | {list the user's Tags, as comma list} | Requires a for loop |
| Tables | {table the user's Orders with columns: ...} | Requires manual HTML table construction |
| Aggregates | {sum of ...}, {average of ...}, {count of ...} | No built-in aggregate functions |
| Between operator | {if X is between 50 and 100} | X >= 50 and X <= 100 |
| Date operators | is today, is this week, is within the last N days | Requires custom filters |
| Send suppression | {the user's Field, required} | default('MOE_NOT_SEND') convention |
| Pluralization | {count, pluralize "item"} | Requires a custom filter |
| Currency formatting | {X, as "currency EUR"} | Requires a custom filter |
Round-Trip Preservation
When converting NLT to Jinja2, NLT-only constructs are preserved in Jinja2 comments so they survive a round-trip conversion:
This means you can convert NLT to Jinja2 for systems that require Jinja2 syntax, then convert back to NLT later without losing NLT-specific features.
Jinja2-Only Features Not in NLT
Three Jinja2 features are intentionally excluded from NLT. These are design decisions, not missing features — each exclusion exists because the capability would undermine a property of the language that NLT guarantees.
| Feature | Jinja2 Syntax | Why NLT excludes it |
|---|---|---|
| Global functions | {{ lipsum() }}, {{ range() }} as expression | See rationale below |
| Recursive loops | {% for item in items recursive %} | See rationale below |
do tag (mutation) | {% do list.append(item) %} | See rationale below |
Why NLT does not support global functions
Jinja2 exposes built-in functions like lipsum(), range() (as an expression, not a loop), dict(), joiner(), and cycler(). Template authors can also register custom global functions via the Jinja2 Environment.
NLT excludes this capability for three reasons:
-
Security boundary. NLT templates are authored by marketers, lifecycle managers, and compliance reviewers — not only by developers. Allowing arbitrary function calls in templates means every function registered in the environment becomes executable by anyone with template-editing permission. In a multi-tenant SaaS platform where templates process customer PII, this is an unacceptable attack surface. A malicious or misconfigured function could leak data across tenants, trigger side effects, or introduce denial-of-service conditions. NLT eliminates the risk by making templates a pure data-transformation layer with no function dispatch.
-
Auditability. One of NLT's design goals is that a compliance reviewer can read a template and understand exactly what it does. Function calls break this guarantee —
{{ custom_fn(user) }}is opaque without reading the function definition. NLT expressions are self-describing:{the user's Balance, as "currency"}tells the reviewer exactly what data is accessed and how it is formatted, with no hidden behaviour. -
Deterministic rendering. NLT templates produce the same output given the same input data. Functions can return different results on each call (timestamps, random values, external lookups), making template output non-deterministic and harder to test. NLT provides
{today}and{now}as the only built-in non-data values, and both are well-defined.
For range() specifically: NLT supports {for i in range 5} as a loop construct (see the syntax mapping table above). What is excluded is range() as a standalone expression that returns a list — e.g., {{ range(5) | list }}. The loop form covers the practical use case.
Why NLT does not support recursive loops
Jinja2's {% for item in items recursive %} renders tree structures by allowing a loop body to call itself with {{ loop(item.children) }}. This is used for nested menus, comment threads, and hierarchical data.
NLT excludes this for two reasons:
-
Email templates do not need tree rendering. NLT is designed for customer communications — emails, SMS, notifications. These are flat documents. In the rare case where a tree structure appears in an email (e.g., a nested product category list), the data should be flattened before it reaches the template. The rendering engine is not the right place to walk a tree — the data pipeline is.
-
Unbounded recursion is a safety risk. Recursive templates can exceed stack depth on malformed data (a cycle in the tree, or a tree deeper than expected). In a multi-tenant email-sending system processing millions of renders per day, a single recursive template that hits a cycle could block a worker, delay other tenants' sends, and produce unclear error diagnostics. NLT avoids this class of failure entirely.
Workaround: Flatten hierarchical data in your application before passing it to the template. A flatten_tree(items, max_depth=3) utility in your data pipeline is safer, testable, and keeps the template readable.
Why NLT does not support the do tag (mutation)
Jinja2's {% do %} tag executes an expression for its side effects — typically {% do list.append(item) %}. It modifies data structures during rendering.
NLT excludes mutation for two reasons:
-
Templates should not change the data they read. In NLT, the contact data, event attributes, and local variables passed to the renderer are read-only inputs. The template transforms them into output text — it does not alter them. This makes rendering a pure function: same inputs, same output. Mutation breaks this property and introduces ordering dependencies (the output of one expression depends on whether a
dotag earlier in the template has already run), making templates harder to reason about and harder to test. -
Concurrent rendering safety. In a production email pipeline, the same contact data structure may be shared across multiple concurrent template renders (e.g., rendering subject and body in parallel, or rendering the same contact through multiple flows). If templates can mutate the data, concurrent renders can interfere with each other — a classic race condition. NLT's no-mutation guarantee makes concurrent rendering safe by construction.
Workaround: Use {set} to create new local variables derived from existing data. {set total to the user's Subtotal times 1.08} computes a new value without modifying the source. For accumulating values across loop iterations, use NLT's built-in aggregates: {sum of the user's Order Totals}, {count of the user's Items}.
The converter handles common patterns automatically. Complex or deeply nested expressions may require manual adjustment. Always review converted templates before deploying to production.
Migration Workflow
A recommended workflow for migrating a template library:
Next Steps
- API Reference: Converter -- full function signatures and ConversionResult fields
- Quick Start -- render your first NLT template