Skip to main content

Template languages

Every template is written in one of three languages. They share the same data, the same helpers and the same settings; only the syntax differs. You pick the language when you create a template, and it stays with the template: it cannot be changed later, so to move a template to another language, create a new one and carry the markup over.

Cheat sheet​

Jinja2-compatibleLiquidHandlebars
Variable{{ invoice.number }}{{ invoice.number }}{{invoice.number}}
Helper with arguments{{ total | money("EUR") }} or {{ money(total, "EUR") }}{{ total | money: "EUR" }}{{money total "EUR"}}
Helper with options{{ qrcode(url, { size: 120 }) }}{{ url | qrcode: size: 120 }}{{qrcode url size=120}}
Loop{% for l in lines %}…{% endfor %}{% for l in lines %}…{% endfor %}{{#each lines}}…{{/each}}
Loop index (from 1){{ loop.index }}{{ forloop.index }}{{add @index 1}}
Condition{% if paid %}…{% elif sent %}…{% else %}…{% endif %}{% if paid %}…{% elsif sent %}…{% else %}…{% endif %}{{#if paid}}…{{else if sent}}…{{else}}…{{/if}}
Comparison{% if total > 1000 %}{% if total > 1000 %}{{#if (gt total 1000)}}
Variable assignment{% set vat = total * 0.19 %}{% assign vat = total | times: 0.19 %}not available; compute in your data or use helpers
Include a partial{% include "footer" %}{% include "footer" %}{{> footer}}
Comment{# … #}{% comment %}…{% endcomment %}{{!-- … --}}
Escape HTML in a valuedone for you{{ name | escape }}done for you
Output HTML as it is{{ html | safe }}done for you{{{html}}} or {{safe html}}
Liquid does not escape

Jinja2 and Handlebars escape every value they print, so a customer name containing <script> prints as text. Liquid prints values as they are. In a Liquid template, add | escape to every value that comes from people you do not control (names, addresses, free text), or that text becomes part of your markup. In the other two languages, opt out of escaping only for HTML you trust.

The same invoice three times​

Data:

{
"invoice": { "number": "2026-0042", "date": "2026-09-11", "paid": false },
"lines": [
{ "description": "Consulting", "qty": 8, "price": 120 },
{ "description": "Travel", "qty": 1, "price": 180 }
]
}

Jinja2-compatible

<h1>Invoice {{ invoice.number }}</h1>
<p>{{ invoice.date | date("dd.MM.yyyy") }}</p>
<table>
{% for line in lines %}
<tr>
<td>{{ loop.index }}. {{ line.description }}</td>
<td>{{ (line.qty * line.price) | money("EUR") }}</td>
</tr>
{% endfor %}
</table>
{% if not invoice.paid %}
<p class="due">Payable within 14 days.</p>
{% endif %}

Liquid

<h1>Invoice {{ invoice.number | escape }}</h1>
<p>{{ invoice.date | date: "dd.MM.yyyy" }}</p>
<table>
{% for line in lines %}
<tr>
<td>{{ forloop.index }}. {{ line.description | escape }}</td>
<td>{{ line.qty | times: line.price | money: "EUR" }}</td>
</tr>
{% endfor %}
</table>
{% unless invoice.paid %}
<p class="due">Payable within 14 days.</p>
{% endunless %}

Handlebars

<h1>Invoice {{invoice.number}}</h1>
<p>{{date invoice.date "dd.MM.yyyy"}}</p>
<table>
{{#each lines}}
<tr>
<td>{{add @index 1}}. {{description}}</td>
<td>{{money (multiply qty price) "EUR"}}</td>
</tr>
{{/each}}
</table>
{{#unless invoice.paid}}
<p class="due">Payable within 14 days.</p>
{{/unless}}

All three print the same document: Invoice 2026-0042, 11.09.2026 and the line totals 960,00 € and 180,00 € when the template's locale is de-DE.

Which one to choose​

  • Jinja2-compatible if you write Python or move from apitemplate.io. It has the most expressive syntax: arithmetic, set, macros and inline object literals. The importer keeps apitemplate.io templates unchanged and maps its custom filters.
  • Liquid if you know Shopify or move from PDFMonkey. It is the most restrictive of the three, which makes it a good fit when people outside your team edit templates. Formfeed's Liquid runs with strict filters, so an unknown filter is an error instead of an empty string. Remember that it does not escape (see above).
  • Handlebars if you work in JavaScript or move from jsreport. Templates stay close to plain HTML; logic goes through helpers such as eq, gt and and.

How each language calls a helper​

The helpers are the same functions in all three languages; only the way you pass them arguments differs. Take image(url, { width, height, fit, alt }):

LanguageCallWhat the helper receives
Jinja2{{ image(p, { width: 120, fit: "cover" }) }}, {{ p | image({ width: 120 }) }} or {{ image(p, width=120, fit="cover") }}the value, then the options object
Liquid{{ p | image: width: 120, fit: "cover" }}the value before |, then every name: value collected into one options object
Handlebars{{image p width=120 fit="cover"}}the positional arguments, then every name=value collected into one options object

So in Jinja2 every helper works as a function and as a filter, and the value before | becomes the first argument. In Liquid a helper is always a filter: a helper that needs no input still needs one, as in {{ '' | pageBreak }}, and positional arguments come before the named ones ({{ total | money: "USD" }}). Liquid cannot write an object literal, so options are always name: value pairs. Every helper's call in all three languages is in the helper reference.

Missing values and errors​

SituationJinja2LiquidHandlebars
A variable or field that is not in the dataempty textempty textempty text
A field of something that is missing ({{ a.b.c }} without a)empty textempty textempty text
An unknown filter or helperthe render fails (template_runtime_error)the template does not compile (template_syntax_error)the render fails (template_runtime_error)
A partial that does not existthe render failsthe render failsthe render fails

Helpers get the missing value as nothing and decide themselves what to print; the one to watch is date, which prints today's date for a missing, null or empty value, so put an optional date inside a condition. A missing value never stops a render, so check templates against your data sets: the editor and formfeed validate warn about every variable the data set on screen does not contain. Use default for a value that may legitimately be missing (see Data).

Differences to know about​

Jinja2​

The Jinja2-compatible engine is Nunjucks, which runs the same in the browser preview and on the server. Almost all Jinja2 syntax works. The exceptions are the Python-only parts:

  • "%.2f" % value string formatting does not work; it prints NaN. Use number(2) or money, or "{:,.2f}".format(value).
  • loop.cycle() does not exist: use loop.index % 2, or cycler(): {% set row = cycler("odd", "even") %} and {{ row.next() }} in the loop.
  • Tests take parentheses: is divisibleby(3), not is divisibleby 3, which is a syntax error. The sequence test does not exist (use is iterable), and sameas compares values like == rather than identity.
  • macro, call, filter and autoescape blocks follow Nunjucks semantics, which differ from Python Jinja2 in details. Check their output in the preview.
  • A {% set %} inside a loop changes a variable that was set before the loop, and the new value stays after the loop; Python Jinja2 keeps the outer value. A variable set for the first time inside the loop is gone after it, as in Python.

Common string methods (.upper(), .replace(), .split(), .strip(), .format() with Python format specs such as "{:,.2f}") work, as do namespace() with {% set ns.total = … %}, True, False and None, and the Jinja2 arguments of truncate, sum(attribute=…), map(attribute=…), selectattr and rejectattr. A list or dict printed directly looks the way Python prints it ([1, 2], {'a': 1}).

Liquid​

  • Output is not escaped. Use | escape on values from outside, as described above.
  • Dates use date-fns tokens, not strftime: {{ invoice.date | date: "dd.MM.yyyy" }}. A Shopify format such as "%d.%m.%Y" prints garbage. The tokens are listed with the date helper in the helper reference.
  • Arithmetic is done with filters: {{ qty | times: price }}, plus, minus, divided_by. They are Formfeed's add, subtract, multiply and divide, so 7 | divided_by: 2 is 3.5, not 3; add | floor when you need the integer.
  • Filters run in strict mode: an unknown filter stops the template from compiling, instead of printing an empty string.
  • Named arguments become one options object. | image: width: 120, fit: "cover" reaches the helper as { width: 120, fit: "cover" }, exactly what Jinja2's object literal and Handlebars' width=120 pass.
  • A filter cannot be used inside a condition. Assign the result first: {% assign big = total | gt: 1000 %}{% if big %}…{% endif %}; for plain comparisons write {% if total > 1000 %}.
  • Besides Formfeed's helpers, these standard Liquid filters work as Shopify documents them: abs, append, at_least, at_most, capitalize, ceil, compact, concat, escape, escape_once, find, find_exp, find_index, floor, group_by_exp, has, lstrip, modulo, newline_to_br, prepend, reject, reject_exp, remove, remove_first, replace_first, reverse, rstrip, slice, sort_natural, strip, strip_html, strip_newlines, truncatewords, uniq, url_decode, url_encode and where_exp. These names belong to Formfeed helpers instead, so the helper runs and may behave differently from Shopify: date, default, divided_by, downcase, first, group_by, join, json, last, map, minus, plus, raw, replace, round, size, sort, split, sum, times, truncate, upcase and where.

Handlebars​

  • Handlebars has no arithmetic or comparison operators. Use the add, subtract, multiply and divide helpers and the eq, ne, gt, gte, lt, lte, and, or and not helpers, as subexpressions in parentheses: {{money (multiply qty price)}}, {{#if (gt total 1000)}}.
  • Inside {{#each}} and {{#with}} the names refer to the current item ({{description}}). Reach the outer data with ../ ({{../currency}}) or from the root with @root ({{@root.invoice.number}}); {{#each lines as |line i|}} names the item and the index.
  • When a field has the same name as a helper and you mention it without arguments ({{date}}, {{number}}), Formfeed prints the field of the current item. With arguments ({{date invoice.date "P"}}) it is the helper.
  • {{> footer}} renders the partial with the current context.

Partials​

Shared fragments are included with {% include "footer" %} (Jinja2 and Liquid) or {{> footer}} (Handlebars). A Liquid partial pulled in with {% render "footer" %} instead sees only brand and the values passed to it ({% render "footer", invoice: invoice %}), not the template's data; {% include "footer" %} sees all of it. In Jinja2, {% import "macros" as m %} and {% from "macros" import price %} load macros from a partial, so a set of macros can live in one partial and serve several templates.

formfeed templates push stores the partials a template includes or imports with its version, so API renders and the editor resolve them; see Partials. Shared partials of the organisation are included with the same syntax; a template only finds those written for its own language.

Every language also sees the organisation's brand kit as brand: {{ brand.legal_footer }} in Jinja2 and Liquid, {{brand.legal_footer}} in Handlebars. A brand field in your data takes its place.

Helpers​

Formatting, text, collections, logic, QR and barcodes, charts and images are the same in all three languages. They are listed with their signatures and an example per language in the helper reference.