Skip to content

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:

Jinja2NLT 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

  1. Single braces, not double. NLT uses { } for everything. No {{ }} or {% %} distinction.
  2. No pipe operator. NLT uses comma-separated modifiers instead of | pipes.
  3. Natural language source phrases. the user's X instead of UserAttribute['X'].
  4. Built-in send suppression. required replaces default('MOE_NOT_SEND') with proper suppression tracking.
  5. Built-in display blocks. {list} and {table} generate styled HTML directly -- no loop boilerplate needed.
  6. Whitespace control. NLT uses {-...-} to trim surrounding whitespace, equivalent to Jinja2's {%- -%}.
  7. Literal expressions. NLT supports math and filters on literal values via {literal ...}, replacing Jinja2's {{ 3 + 2 }} and {{ "hello" | upper }}.
  8. 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:

python
from pyrx_nlt.converter import (
convert_jinja2_to_nlt,
convert_nlt_to_jinja2,
detect_template_language,
count_expressions,
)

Jinja2 to NLT

python
template = "Hi {{ UserAttribute['First Name']|default('Customer') }}"
nlt, result = convert_jinja2_to_nlt(template)
 
print(nlt)
# 'Hi `{the user\'s First Name, or "Customer"}`'
 
print(result.count)
# 1

NLT to Jinja2

python
template = '`{the user\'s First Name, or "Customer"}`'
j2, result = convert_nlt_to_jinja2(template)
 
print(j2)
# "{{ UserAttribute['First Name']|default('Customer') }}"
 
print(result.count)
# 1
 
if result.warnings:
print(f"Warnings: {result.warnings}")

Detect Template Language

python
lang = detect_template_language(template_html)
# Returns: "nlt", "jinja2", "mixed", or "none"

Use this to determine whether a template needs conversion before processing.

Count Expressions

python
nlt_count, j2_count = count_expressions(template_html)
print(f"NLT: {nlt_count}, Jinja2: {j2_count}")
Tip

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:

FeatureNLT SyntaxJinja2 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 operatorsis today, is this week, is within the last N daysRequires 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:

python
# NLT → Jinja2
nlt = "`{sum of the user's Order Amounts}`"
j2, _ = convert_nlt_to_jinja2(nlt)
# j2: "`{# NLT: `{sum of the user's Order Amounts}` #}"
 
# Jinja2 → NLT (round-trip recovery)
recovered, _ = convert_jinja2_to_nlt(j2)
# recovered: "`{sum of the user's Order Amounts}`"

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.

FeatureJinja2 SyntaxWhy NLT excludes it
Global functions{{ lipsum() }}, {{ range() }} as expressionSee 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:

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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:

  1. 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 do tag earlier in the template has already run), making templates harder to reason about and harder to test.

  2. 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}.

Warning

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:

python
from pyrx_nlt.converter import (
convert_jinja2_to_nlt,
detect_template_language,
count_expressions,
)
 
for template in all_templates:
lang = detect_template_language(template.body)
 
if lang == "nlt":
continue # Already converted
 
if lang == "none":
continue # Plain HTML, no expressions
 
if lang == "mixed":
log.warning(f"Mixed template needs manual review: {template.id}")
continue
 
# Convert Jinja2 → NLT
nlt_body, result = convert_jinja2_to_nlt(template.body)
nlt_subject, _ = convert_jinja2_to_nlt(template.subject)
 
log.info(f"Converted {result.count} expressions in {template.id}")
 
if result.unhandled:
log.warning(f"Unhandled expressions: {result.unhandled}")
 
template.body = nlt_body
template.subject = nlt_subject
template.save()

Next Steps