# Template language

> Jinja syntax, autoescaping, built-in filters and every custom filter for money, dates, QR codes, barcodes, charts and layout, with sandbox limits.



Templates are written in Jinja syntax: HTML and CSS with expressions such as `{{ customer.name }}` and blocks such as `{% for item in items %}`. The engine is a Rust implementation built on MiniJinja, extended with filters for documents. The dashboard preview and the production renderer use the same engine, so what you see in the editor is what the API produces.

## Where the template language runs [#where-the-template-language-runs]

* The **body** and **head** of a template, and `input.html` and `input.head` for HTML renders (when `templating` is on, which is the default as soon as you send `data`)
* **Header and footer** HTML and simple-mode text
* **Markdown** input, before the Markdown is converted to HTML
* **Output filenames**, where your data is available under `data`, as in `invoice-{{ data.number }}.pdf`

## Syntax [#syntax]

### Variables [#variables]

Keys of the `data` object are available as top-level variables. Use dots or brackets for nested values:

```jinja
<h1>Invoice {{ invoice.number }}</h1>
<p>{{ customer["name"] }} · {{ items[0].description }}</p>
```

The whole object is also available as `data`, and `render` describes the render itself:

| Variable                                                              | Description                                                                                                                                                                                            |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `render.id`, `render.created_at`, `render.template_id`, `render.test` | The render ID, its time (RFC 3339), the template ID (empty for HTML, URL and Markdown renders) and whether it is a test render                                                                         |
| `render.invoice`                                                      | The invoice amounts computed by the platform (line net amounts, VAT breakdown, totals) for templates with invoice data; see [Print the computed amounts](/docs/e-invoicing#print-the-computed-amounts) |
| `render.einvoice`                                                     | The same amounts plus the `profile`, only when the PDF embeds an e-invoice                                                                                                                             |

### Filters [#filters]

Filters transform a value. They take arguments and can be chained:

```jinja
{{ customer.name | upper }}
{{ note | default("No notes") | truncate(80) }}
{{ items | map(attribute="quantity") | sum }}
```

### Tests [#tests]

Tests ask a question about a value with `is`:

```jinja
{% if discount is defined and discount > 0 %}…{% endif %}
{% if item.note is none %}…{% endif %}
{% if loop.index is even %}…{% endif %}
```

Available tests include `defined`, `undefined`, `none`, `boolean`, `true`, `false`, `number`, `integer`, `float`, `string`, `sequence`, `mapping`, `iterable`, `odd`, `even`, `divisibleby(n)`, `lower`, `upper`, `sameas`, `in`, and the comparison tests `eq`, `ne`, `lt`, `le`, `gt` and `ge`.

### Conditions [#conditions]

```jinja
{% if invoice.status == "paid" %}
  <span class="badge badge-paid">Paid</span>
{% elif invoice.status == "overdue" %}
  <span class="badge badge-overdue">Overdue</span>
{% else %}
  <span class="badge">Open</span>
{% endif %}
```

Expressions support `==`, `!=`, `<`, `<=`, `>`, `>=`, `and`, `or`, `not`, `in`, arithmetic (`+`, `-`, `*`, `/`, `//`, `%`, `**`), string concatenation with `~`, and an inline conditional:

```jinja
{{ "Paid" if invoice.status == "paid" else "Due" }}
```

### Loops [#loops]

```jinja
<tbody>
  {% for item in items %}
    <tr class="{{ loop.cycle('odd', 'even') }}">
      <td>{{ loop.index }}</td>
      <td>{{ item.description }}</td>
      <td>{{ item.amount | format_currency("EUR", "de-DE") }}</td>
    </tr>
  {% else %}
    <tr><td colspan="3">No items</td></tr>
  {% endfor %}
</tbody>
```

Inside a loop, `loop` describes the iteration:

| Variable                          | Value                                                     |
| --------------------------------- | --------------------------------------------------------- |
| `loop.index`, `loop.index0`       | Position, counting from 1 or from 0                       |
| `loop.revindex`, `loop.revindex0` | Position counted from the end                             |
| `loop.first`, `loop.last`         | Whether this is the first or last iteration               |
| `loop.length`                     | Number of items                                           |
| `loop.cycle(a, b, …)`             | Cycles through the given values                           |
| `loop.previtem`, `loop.nextitem`  | The neighbouring items                                    |
| `loop.changed(value)`             | `true` when the value differs from the previous iteration |
| `loop.depth`, `loop.depth0`       | Nesting level in a `recursive` loop                       |

You can filter and unpack while looping:

```jinja
{% for item in items if item.quantity > 0 %}…{% endfor %}
{% for key, value in totals.items() %}…{% endfor %}
```

The `else` block runs when the sequence is empty, which is a tidy way to handle empty tables.

### Assignments [#assignments]

```jinja
{% set subtotal = items | sum_by("amount") %}
{% set tax = subtotal * invoice.tax_rate %}
```

Variables set inside a loop don't survive the iteration. Use a namespace to accumulate across iterations:

```jinja
{% set totals = namespace(weight=0) %}
{% for parcel in parcels %}
  {% set totals.weight = totals.weight + parcel.weight %}
{% endfor %}
<p>Total weight: {{ totals.weight | format_number("en-GB", 2) }} kg</p>
```

Block assignments capture markup:

```jinja
{% set address %}
  {{ customer.street }}<br>
  {{ customer.postcode }} {{ customer.city }}
{% endset %}
```

### with [#with]

`with` scopes one or more variables to a block:

```jinja
{% with total = items | sum_by("amount") %}
  <p>Total: {{ total | format_currency("EUR", "en-IE") }}</p>
{% endwith %}
```

### Macros and call [#macros-and-call]

Macros are reusable snippets:

```jinja
{% macro money(amount, currency, locale) -%}
  <span class="amount">{{ amount | format_currency(currency, locale) }}</span>
{%- endmacro %}

<td>{{ money(item.amount, invoice.currency, invoice.locale) }}</td>
```

`call` passes a block of markup to a macro, which renders it with `caller()`:

```jinja
{% macro panel(title) %}
  <section class="panel"><h2>{{ title }}</h2>{{ caller() }}</section>
{% endmacro %}

{% call panel("Payment details") %}
  <p>IBAN: {{ payment.iban }}</p>
{% endcall %}
```

### Raw blocks [#raw-blocks]

Everything inside `raw` is copied verbatim, which is useful when the document itself talks about template syntax:

```jinja
{% raw %}Write {{ customer.name }} to insert the customer name.{% endraw %}
```

### Whitespace control [#whitespace-control]

A minus sign next to a block delimiter strips the whitespace on that side. This matters in HTML where stray spaces can affect inline layout:

```jinja
{%- for tag in tags -%}
  {{ tag }}{% if not loop.last %}, {% endif %}
{%- endfor -%}
```

### Comments [#comments]

```jinja
{# Totals come from the billing system and are not recalculated here #}
```

Comments never appear in the output.

### Includes and imports [#includes-and-imports]

Shared partials at workspace level, such as a legal footer or an address block, are planned. They will be versioned like templates and available through `include` and `import`. Templates cannot read files from disk or fetch other templates.

## Autoescaping [#autoescaping]

HTML sources are autoescaped: characters such as `<`, `>` and `&` in your data become HTML entities, so data can never inject markup by accident. The head, the header and the footer are escaped too.

Markdown templates are escaped the same way, and a value inside a code span or a fenced code block prints exactly as your data has it: `{{ release.install_command }}` in a code block shows `npm install x && y migrate`, not `&amp;&amp;`.

To output HTML that you trust, mark it safe:

```jinja
{{ product.description_html | safe }}
```

Only use `safe` for content you control. For text written by users, use the `markdown` filter instead, which sanitizes its output.

Filters that build markup — `qrcode`, `barcode`, `render_chart`, `table`, `markdown`, `nl2br`, `page_break()` and the `render_*` aliases — return safe HTML already, so you never need `safe` with them. `tojson` produces JSON that is safe to embed in HTML.

## Undefined values [#undefined-values]

Missing variables render as an empty string, and so does attribute access on a missing value: `{{ customer.address.city }}` is empty when `address` isn't there. This keeps a missing field from breaking a whole document.

Two ways to handle gaps deliberately:

```jinja
{{ customer.vat_id | default("—") }}
{% if customer.vat_id is defined %}VAT {{ customer.vat_id }}{% endif %}
```

A JSON `null` is a defined value, so `default` doesn't replace it unless you also pass `true`:

```jinja
{{ customer.vat_id | default("—", true) }}
```

To catch typos and missing data during development, set `strict_undefined: true` in the render request. Referencing an undefined variable then fails with `template_runtime_error` and the path that was missing, instead of rendering an empty space.

## Python method compatibility [#python-method-compatibility]

Templates written for Python Jinja2 often call Python methods on strings and dictionaries. These work here too:

**Strings:** `capitalize`, `count`, `endswith`, `find`, `format`, `isalnum`, `isalpha`, `isascii`, `islower`, `isnumeric`, `isspace`, `isupper`, `join`, `lower`, `lstrip`, `replace`, `rfind`, `rstrip`, `split`, `splitlines`, `startswith`, `strip`, `title`, `upper`

**Maps:** `get`, `items`, `keys`, `values`

```jinja
{{ customer.email.strip().lower() }}
{{ "{} of {}".format(loop.index, items | length) }}
{{ settings.get("currency", "EUR") }}
{% if order.reference.startswith("TEST-") %}…{% endif %}
{% for key, value in totals.items() %}…{% endfor %}
```

Values are immutable, so list methods that modify in place, such as `append`, are not available. Build lists with filters, or accumulate with `namespace`.

## Built-in filters [#built-in-filters]

The Jinja2 built-ins are available. The most useful ones for documents:

| Filter                       | Description                                                  | Example                                             |
| ---------------------------- | ------------------------------------------------------------ | --------------------------------------------------- |
| `abs`                        | Absolute value                                               | `{{ -3 \| abs }}`                                   |
| `attr(name)`                 | Look up an attribute by name                                 | `{{ item \| attr("sku") }}`                         |
| `batch(n)`                   | Split a sequence into chunks of `n`                          | `{% for row in items \| batch(3) %}`                |
| `capitalize`                 | First letter upper case, rest lower case                     | `{{ "hello world" \| capitalize }}`                 |
| `count`, `length`            | Number of items or characters                                | `{{ items \| length }}`                             |
| `default(value)`, `d(value)` | Fallback for undefined values                                | `{{ note \| default("—") }}`                        |
| `dictsort`                   | Sort a mapping by key                                        | `{% for k, v in totals \| dictsort %}`              |
| `escape`, `e`                | Escape HTML                                                  | `{{ raw_text \| escape }}`                          |
| `first`, `last`              | First or last item                                           | `{{ items \| first }}`                              |
| `float`, `int`               | Convert to a number                                          | `{{ "12.50" \| float }}`                            |
| `format(…)`                  | Printf-style formatting                                      | `{{ "%s of %s" \| format(3, 10) }}`                 |
| `groupby(attribute)`         | Group a list by an attribute                                 | `{% for g in items \| groupby("category") %}`       |
| `indent(n)`                  | Indent lines                                                 | `{{ text \| indent(4) }}`                           |
| `items`                      | Key-value pairs of a mapping                                 | `{% for k, v in totals \| items %}`                 |
| `join(separator)`            | Join a sequence into a string                                | `{{ tags \| join(", ") }}`                          |
| `list`                       | Convert to a list                                            | `{{ "abc" \| list }}`                               |
| `lower`, `upper`, `title`    | Change case                                                  | `{{ name \| upper }}`                               |
| `map(attribute=…)`           | Pick an attribute from every item                            | `{{ items \| map(attribute="sku") \| join(", ") }}` |
| `max`, `min`                 | Largest or smallest value                                    | `{{ prices \| max }}`                               |
| `random`                     | A random item (avoid in documents that must be reproducible) | `{{ quotes \| random }}`                            |
| `reject`, `select`           | Keep items that fail or pass a test                          | `{{ numbers \| select("odd") \| list }}`            |
| `rejectattr`, `selectattr`   | The same, by attribute                                       | `{{ items \| selectattr("taxable") \| list }}`      |
| `replace(old, new)`          | Replace text                                                 | `{{ phone \| replace(" ", "") }}`                   |
| `reverse`                    | Reverse a sequence                                           | `{{ items \| reverse \| list }}`                    |
| `round(precision)`           | Round a number                                               | `{{ 3.14159 \| round(2) }}`                         |
| `safe`                       | Mark a string as HTML                                        | `{{ snippet \| safe }}`                             |
| `slice(n)`                   | Split into `n` columns                                       | `{% for column in items \| slice(2) %}`             |
| `sort(attribute=…)`          | Sort a sequence                                              | `{{ items \| sort(attribute="position") }}`         |
| `string`                     | Convert to a string                                          | `{{ 42 \| string }}`                                |
| `sum(attribute=…)`           | Sum numbers                                                  | `{{ items \| sum(attribute="amount") }}`            |
| `tojson(indent=…)`           | Serialize as JSON                                            | `{{ chart \| tojson }}`                             |
| `trim`                       | Strip surrounding whitespace                                 | `{{ note \| trim }}`                                |
| `truncate(n)`                | Shorten text                                                 | `{{ description \| truncate(60) }}`                 |
| `unique`                     | Remove duplicates                                            | `{{ tags \| unique \| list }}`                      |
| `urlencode`                  | Percent-encode for URLs                                      | `{{ query \| urlencode }}`                          |
| `wordcount`                  | Count words                                                  | `{{ text \| wordcount }}`                           |
| `wordwrap(width)`            | Wrap long text                                               | `{{ text \| wordwrap(60) }}`                        |

Global functions include `range(start, stop, step)`, `dict(…)`, `namespace(…)`, `cycler(…)` and `joiner(separator)`.

## Formatting filters [#formatting-filters]

These use CLDR locale data, so number, currency and date formats match local conventions.

### format\_currency [#format_currency]

```jinja
{{ amount | format_currency(currency, locale, decimals) }}
```

Formats a number as an amount of money. `currency` is an ISO 4217 code such as `"EUR"`. `locale` is a BCP 47 tag and defaults to `"en"`. `decimals` is optional and defaults to the currency's usual number of decimal places. Values are rounded half up.

```jinja
{{ 1234.5 | format_currency("EUR", "de-DE") }}   {# 1.234,50 € #}
{{ 1234.5 | format_currency("USD", "en-US") }}   {# $1,234.50 #}
{{ 1234.5 | format_currency("USD", "en-US", 0) }} {# $1,235 #}
```

### format\_number [#format_number]

```jinja
{{ value | format_number(locale, decimals) }}
```

Formats a number with the locale's grouping and decimal separators. Without `decimals`, the locale's standard format is used.

```jinja
{{ 1234567.891 | format_number("en-US", 2) }}  {# 1,234,567.89 #}
{{ 1234567.891 | format_number("de-DE", 2) }}  {# 1.234.567,89 #}
{{ 12.5 | format_number("en-GB", 1) }}         {# 12.5 #}
```

### format\_percent [#format_percent]

```jinja
{{ ratio | format_percent(locale, decimals) }}
```

Formats a ratio as a percentage: `0.19` becomes 19 %. Pass `decimals` for fractional percentages.

```jinja
{{ 0.19 | format_percent("de-DE") }}       {# 19 % #}
{{ 0.19 | format_percent("en-US") }}       {# 19% #}
{{ 0.0725 | format_percent("en-US", 2) }}  {# 7.25% #}
```

### number\_to\_words [#number_to_words]

```jinja
{{ number | number_to_words(lang) }}
```

Writes a whole number in words, for cheques and contracts. `lang` is `en`, `de`, `fr`, `es` or `nl`. Pass an integer; for amounts with cents, convert the whole part and add the cents separately.

```jinja
{{ 1250 | number_to_words("en") }}  {# one thousand two hundred and fifty #}
```

## Date and time filters [#date-and-time-filters]

Dates can be ISO 8601 strings such as `"2026-09-14"` or `"2026-09-14T09:30:00Z"`, or values produced by `strptime`, `epoch_to_datetime`, `date_add` and `now`.

### format\_date [#format_date]

```jinja
{{ value | format_date(style_or_pattern, locale, tz) }}
```

Formats a date or date-time. The first argument is either a locale-aware style — `short`, `medium`, `long` or `full` — or a strftime pattern such as `"%d.%m.%Y"`. `locale` defaults to `"en"`, and `tz` is an IANA time zone that defaults to `"UTC"`.

```jinja
{{ "2026-09-14" | format_date("short", "en-US") }}   {# 9/14/26 #}
{{ "2026-09-14" | format_date("medium", "en-US") }}  {# Sep 14, 2026 #}
{{ "2026-09-14" | format_date("long", "en-GB") }}    {# 14 September 2026 #}
{{ "2026-09-14" | format_date("full", "de-DE") }}    {# Montag, 14. September 2026 #}
{{ "2026-09-14" | format_date("%d.%m.%Y") }}         {# 14.09.2026 #}
{{ "2026-09-14T09:30:00Z" | format_date("%d %b %Y, %H:%M", "en-GB", "Europe/Berlin") }}  {# 14 Sep 2026, 11:30 #}
```

Common pattern directives:

| Directive        | Meaning                      | Example            |
| ---------------- | ---------------------------- | ------------------ |
| `%Y`, `%y`       | Year, four or two digits     | `2026`, `26`       |
| `%m`, `%d`       | Month and day, zero-padded   | `09`, `14`         |
| `%B`, `%b`       | Month name, full or short    | `September`, `Sep` |
| `%A`, `%a`       | Weekday name, full or short  | `Monday`, `Mon`    |
| `%H`, `%M`, `%S` | Hours (24), minutes, seconds | `09`, `30`, `00`   |
| `%I`, `%p`       | Hours (12) and AM/PM         | `09`, `AM`         |
| `%j`             | Day of the year              | `257`              |
| `%z`, `%Z`       | UTC offset, time zone name   | `+0200`, `CEST`    |

### date\_add [#date_add]

```jinja
{{ value | date_add(days=…, months=…) }}
```

Shifts a date by days, months or both. Use it for due dates and validity periods.

```jinja
{{ invoice.issue_date | date_add(days=30) | format_date("medium", "en-US") }}
{{ "2026-09-14" | date_add(months=3) | format_date("%Y-%m-%d") }}  {# 2026-12-14 #}
```

### now [#now]

```jinja
{{ now(tz) }}
```

The current time, in UTC unless you pass an IANA time zone. Format it with `format_date` or `strftime`.

```jinja
Generated {{ now("Europe/Berlin") | format_date("%d.%m.%Y %H:%M", "de-DE", "Europe/Berlin") }}
```

Templates that use `now()` produce different output every time they run. When a document must be reproducible, pass the date in your data instead.

### strptime [#strptime]

```jinja
{{ text | strptime(format) }}
```

Parses a date-time from text that isn't ISO 8601.

```jinja
{{ "14/09/2026" | strptime("%d/%m/%Y") | format_date("long", "en-GB") }}  {# 14 September 2026 #}
```

### strftime [#strftime]

```jinja
{{ datetime | strftime(format) }}
```

Formats a date-time with a strftime pattern. Month and weekday names are English; use `format_date` when you need them localized.

```jinja
{{ strptime("2026-09-14 09:30", "%Y-%m-%d %H:%M") | strftime("%H:%M on %d %b") }}  {# 09:30 on 14 Sep #}
```

### epoch\_to\_datetime [#epoch_to_datetime]

```jinja
{{ epoch_to_datetime(seconds, tz_hours=0) }}
```

Converts a Unix timestamp in seconds to a date-time, optionally at a fixed UTC offset.

```jinja
{{ epoch_to_datetime(1789657594) | strftime("%Y-%m-%d %H:%M") }}             {# 2026-09-17 15:06 #}
{{ epoch_to_datetime(1789657594, tz_hours=2) | strftime("%Y-%m-%d %H:%M") }} {# 2026-09-17 17:06 #}
```

## Codes [#codes]

### qrcode [#qrcode]

```jinja
{{ value | qrcode(size=…, margin=…, ecc=…, color=…, background=…, style=…, class=…, alt=…, inline=…) }}
```

Renders a QR code as an image element containing an SVG, so it stays sharp at any print resolution. It can also be called as a function: `{{ qrcode(value, size=160) }}`.

| Argument     | Description                                                                                                                  |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `size`       | Width and height in pixels, or a CSS length such as `"30mm"`. Default `200`                                                  |
| `margin`     | Quiet zone around the code, in modules. Default `4`; `0` removes it                                                          |
| `ecc`        | Error correction level: `L`, `M` (default), `Q` or `H`                                                                       |
| `color`      | Colour of the modules: hex (`#1b2a4a`), `rgb(…)`, `rgba(…)` or a colour name. Default black                                  |
| `background` | Background colour; `transparent` leaves it out. Default white                                                                |
| `style`      | Inline CSS applied to the generated image element                                                                            |
| `class`      | CSS class of the image element                                                                                               |
| `alt`        | Alternative text of the image. Default `"QR code"`                                                                           |
| `inline`     | `true` outputs the `<svg>` element itself instead of an image (`style`, `class` and `alt` then don't apply). Default `false` |

```jinja
{{ ticket.url | qrcode(size=160, margin=0, ecc="Q") }}
{{ invoice.payment_link | qrcode(size=120, color="#1b2a4a", style="float:right") }}
```

Use a higher error correction level (`Q` or `H`) for codes that will be printed on labels or may be partly covered.

### barcode [#barcode]

```jinja
{{ value | barcode(type, module_width=…, module_height=…, quiet_zone=…, font_size=…, text_distance=…, write_text=…, foreground=…, background=…, font_family=…, style=…, class=…, alt=…, inline=…) }}
```

Renders a 1D barcode as SVG, with the human-readable text below it. The function form is `{{ barcode(value, "code128") }}`.

| Argument        | Description                                                                              |
| --------------- | ---------------------------------------------------------------------------------------- |
| `type`          | The symbology, from the table below. Default `code128`                                   |
| `module_width`  | Width of the narrowest bar in millimetres (0.05 to 5). Default `0.2`                     |
| `module_height` | Height of the bars in millimetres (1 to 500). Default `15`                               |
| `quiet_zone`    | Empty space left and right of the bars in millimetres. Default `6.5`                     |
| `font_size`     | Size of the text in points (1 to 72). Default `10`                                       |
| `text_distance` | Distance from the bars to the text baseline in millimetres. Default `5`                  |
| `write_text`    | `false` leaves out the human-readable text. Default `true`                               |
| `foreground`    | Colour of the bars and the text, as for `qrcode`; `color` is accepted too. Default black |
| `background`    | Background colour; `transparent` leaves it out. Default white                            |
| `font_family`   | CSS font family of the text. Default `monospace`                                         |
| `style`         | Inline CSS applied to the generated image element                                        |
| `class`         | CSS class of the image element                                                           |
| `alt`           | Alternative text of the image. Default: the encoded text                                 |
| `inline`        | `true` outputs the `<svg>` element itself instead of an image. Default `false`           |

The symbologies:

| Type                          | Use                                         |
| ----------------------------- | ------------------------------------------- |
| `code128`, `code39`, `code93` | General purpose, alphanumeric               |
| `ean8`, `ean13`, `jan`        | Retail articles                             |
| `upca`, `upce`                | Retail articles in North America            |
| `itf`, `gtin14`               | Cartons and logistics units                 |
| `gs1_128`                     | Logistics data with application identifiers |
| `codabar`                     | Libraries, blood banks, logistics           |
| `isbn10`, `isbn13`, `issn`    | Books and periodicals                       |
| `pzn`                         | Pharmaceutical products                     |

Check digits are calculated for `isbn10`, `isbn13`, `issn`, `gtin14`, `jan`, `pzn` and `gs1_128`. Sizes are given in millimetres (`module_width`, `module_height`, `quiet_zone`, `text_distance`) and the text size in points (`font_size`); CSS lengths such as `"0.3mm"` or `"12pt"` work too.

```jinja
{{ parcel.tracking_number | barcode("code128", module_height=14) }}
{{ product.gtin | barcode("ean13") }}
```

## Data helpers [#data-helpers]

### sum\_by [#sum_by]

```jinja
{{ list | sum_by(attribute) }}
```

Adds up one attribute across a list of objects. With `items` set to `[{"amount": 120.0}, {"amount": 80.5}]`:

```jinja
{{ items | sum_by("amount") }}                                   {# 200.5 #}
{{ items | sum_by("amount") | format_currency("EUR", "en-IE") }}  {# €200.50 #}
```

### group\_by [#group_by]

```jinja
{{ list | group_by(attribute) }}
```

Groups a list of objects by an attribute. Each group has `grouper` (the shared value) and `list` (the items), the same shape as the built-in `groupby` filter.

```jinja
{% for group in items | group_by("category") %}
  <h3>{{ group.grouper }}</h3>
  <ul>
    {% for item in group.list %}<li>{{ item.name }}</li>{% endfor %}
  </ul>
  <p>Subtotal: {{ group.list | sum_by("amount") | format_currency("EUR", "en-IE") }}</p>
{% endfor %}
```

### json and tojson [#json-and-tojson]

```jinja
{{ value | tojson(indent=…) }}
```

Serializes a value as JSON, escaped so that it is safe inside HTML. `json` is an alias of `tojson`. This is the safe way to hand data to a script in the page:

```jinja
<script type="application/json" id="chart-data">{{ chart | tojson }}</script>
{{ {"sku": "A1", "qty": 2} | tojson }}  {# {"sku":"A1","qty":2} #}
```

### gt, gte, lt, lte [#gt-gte-lt-lte]

```jinja
{{ gt(a, b) }}
```

Comparison helpers that return `true` or `false`, equivalent to `a > b`, `a >= b`, `a < b` and `a <= b`. They also work as filters, where the piped value is the first argument. They exist mainly for templates migrated from other systems; in new templates, the operators read better.

```jinja
{% if gt(invoice.total, 1000) %}Approval required{% endif %}
{{ stock.quantity | lte(stock.reorder_level) }}  {# true #}
```

## Content filters [#content-filters]

### markdown [#markdown]

```jinja
{{ text | markdown }}
```

Converts GitHub Flavored Markdown to sanitized HTML. Scripts, event handlers and unsafe URLs are removed, which makes it the right filter for text your users write.

```jinja
{{ invoice.notes | markdown }}
```

With `notes` set to `"**Payment terms:** 30 days.\n\nThank you for your business."`, the output is:

```html
<p><strong>Payment terms:</strong> 30 days.</p>
<p>Thank you for your business.</p>
```

### nl2br [#nl2br]

```jinja
{{ text | nl2br }}
```

Escapes the text and turns line breaks into `<br>` elements. Useful for addresses and free-text fields.

```jinja
{{ customer.address | nl2br }}  {# Example Ltd<br>1 Sample Street<br>Dublin #}
```

### slugify [#slugify]

```jinja
{{ text | slugify }}
```

Turns text into a lowercase, hyphenated identifier, handy in filenames and anchors.

```jinja
{{ "Quarterly Report: Q3 2026" | slugify }}  {# quarterly-report-q3-2026 #}
```

### table [#table]

```jinja
{{ list | table(class=…, columns=…, headers=…) }}
```

Renders a list of objects as an HTML table. Without `columns`, the columns are the union of the objects' keys in the order they first appear, and the keys are used as headers. `columns` selects and orders columns, `headers` gives them labels in the same order, and `class` sets the table's CSS class.

```jinja
{{ items | table(class="items", columns=["sku", "description", "qty"], headers=["SKU", "Description", "Qty"]) }}
```

```html
<table class="items">
  <thead><tr><th>SKU</th><th>Description</th><th>Qty</th></tr></thead>
  <tbody>
    <tr><td>A1</td><td>Copy paper, A4</td><td>2</td></tr>
  </tbody>
</table>
```

Write the table markup yourself when you need per-column styling, totals or repeating headers; `table` is for quick, uniform data.

### image\_data\_uri [#image_data_uri]

```jinja
{{ image_data_uri(url, max_bytes=…) }}
```

Downloads an image and returns it as a `data:` URI, so it is embedded in the document instead of linked. The download goes through the same policy-checked fetcher as page assets — public HTTP and HTTPS only — and is cached. `max_bytes` caps the size and defaults to 5 MB. It also works as a filter.

```jinja
<img src="{{ image_data_uri(company.logo_url) }}" alt="{{ company.name }}" style="height:12mm">
```

This is the reliable way to show a remote image in a header or footer, which renders in an isolated context.

## Layout [#layout]

### page\_break [#page_break]

```jinja
{{ page_break() }}
```

Inserts an element that forces a page break, which is easier to place inside loops than a CSS rule.

```jinja
{% for certificate in certificates %}
  <section class="certificate">…</section>
  {% if not loop.last %}{{ page_break() }}{% endif %}
{% endfor %}
```

See [Pagination basics](/docs/pagination) for the CSS approach and for keeping blocks together.

## Charts [#charts]

### render\_chart [#render_chart]

```jinja
{{ rows | render_chart(type, label=…, value=…, …) }}
{{ render_chart(type, rows, label=…, value=…, …) }}
```

Draws a chart from your data: `bar`, `horizontal-bar`, `line`, `area`, `pie`, `donut`, `radar`, `scatter` or `gauge`. The renderer draws it as SVG with the same chart engine as image templates, so a chart looks the same in both editors and stays sharp at any zoom. Your document needs no script for it: you don't have to turn on `javascript` or wait for a ready flag, and charts work in PDF/A files and e-invoices.

```jinja
{{ monthly_revenue | render_chart("bar", label="month", value="revenue") }}
```

#### Data [#data]

A chart takes its data from a list of rows, or from lists that you give directly.

| Argument | Description                                                                                                                                                        |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `rows`   | A list of objects, one per category: the second argument of the function, or the value the filter is applied to                                                    |
| `label`  | The path of each row's category label, such as `"month"`. It is written as text; a missing value is an empty label                                                 |
| `value`  | The path of each row's value, such as `"revenue"`, or a list of paths for one series each: `["revenue", "costs"]`                                                  |
| `names`  | The series names, one per `value` path. Default: the path's last part with `_` as a space and a capital first letter, so `"amount.net_total"` is named "Net total" |
| `labels` | A list of category labels. With `rows`, it replaces the row labels, for example with formatted months                                                              |
| `values` | A list of numbers for one series, used with `labels` instead of `rows`                                                                                             |
| `series` | A list of series, each `{"name": …, "values": […], "color": …}`, used with `labels` instead of `rows`                                                              |

Paths reach into nested objects and lists: `"amount.net"`, `"totals.0.value"`. Values are numbers or numeric strings such as `"1234.5"`; anything else, including `null` and `""`, leaves a gap. A chart takes at most 10,000 labels, 50 series and 10,000 values per series. When `rows` is missing or `null` in the data, the chart is drawn empty instead of failing, like `table`.

A scatter chart plots points: x comes from `label` (or `labels`), which must then be numbers, and y from `value`. Points with a missing x or y are left out. A `series` entry can also list its points directly: `"points": [{"x": 1, "y": 2}, …]`.

#### Size [#size]

`width` and `height` are CSS lengths in `px`, `mm`, `cm`, `in` or `pt`, or numbers of pixels. The default is `width="160mm"` and `height="70mm"`, and each must be between 16 and 4,000 pixels (96 pixels per inch). In a narrower container, the chart scales down and keeps its proportions.

#### Look [#look]

| Argument         | Description                                                                                                                                                                                                    |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`          | A title above the chart                                                                                                                                                                                        |
| `colors`         | The series colours, as a list of hex colours: `#rgb`, `#rrggbb` or `#rrggbbaa`                                                                                                                                 |
| `highlight_last` | `true` draws the last bar or point of a single-series `bar`, `horizontal-bar`, `line` or `area` chart in the second colour and the others in the first, to set the current period apart from the previous ones |
| `legend`         | `true`, `false` or a position: `"top"`, `"bottom"`, `"left"` or `"right"`. Default: at the bottom for more than one series and for `pie` and `donut`, otherwise hidden                                         |
| `show_values`    | `true` writes the value next to each bar, point or slice                                                                                                                                                       |
| `stacked`        | `true` stacks the series of a bar, line or area chart                                                                                                                                                          |
| `smooth`         | `true` draws smooth curves in line and area charts                                                                                                                                                             |
| `font_family`    | The font of all text. Default `"Inter"`                                                                                                                                                                        |
| `font_size`      | The text size in pixels. Default `12`                                                                                                                                                                          |
| `text_color`     | The text colour, as a hex colour                                                                                                                                                                               |
| `background`     | A background colour, as a hex colour. Without it, the chart is transparent                                                                                                                                     |

#### Axes [#axes]

| Argument             | Description                                                                   |
| -------------------- | ----------------------------------------------------------------------------- |
| `x_title`, `y_title` | Axis titles                                                                   |
| `min`, `max`         | The range of the value axis, and the scale of a `gauge` (0 to 100 by default) |
| `grid`               | `false` hides the grid lines. Default `true`                                  |
| `axis_labels`        | `false` hides the axis labels. Default `true`                                 |

#### Numbers [#numbers]

Numbers on the axis, in value labels and in pie and donut labels are plain numbers unless you choose a format:

| Argument   | Description                                                                                                    |
| ---------- | -------------------------------------------------------------------------------------------------------------- |
| `format`   | `"number"` (default), `"money"` or `"percent"`. Percent values are in percent units: `7.2` is written as 7.2 % |
| `currency` | An ISO 4217 code such as `"EUR"`; required with `"money"`                                                      |
| `locale`   | A BCP 47 tag such as `"de-DE"` for separators and symbols. Default `"en"`                                      |
| `compact`  | `true` writes short numbers: 138,400 becomes 138K                                                              |
| `decimals` | The number of decimal places, from 0 to 6                                                                      |

#### Reference lines [#reference-lines]

`reference` draws a line at a value across a `bar`, `line` or `area` chart, such as a monthly target: horizontal, or vertical on a `horizontal-bar` chart. Pass a list for several lines, at most 10. `reference_label` labels the line (a list labels several lines in order), and `reference_color` sets its colour.

#### Output [#output]

`render_chart` returns a `<dda-chart>` placeholder element that describes the chart. The renderer replaces it with an `<svg>` element before the document is printed, in the body, the header and the footer, and in `format: "html"` output. If a chart can't be drawn, it leaves an empty box of the chart's size and an `asset_failed` warning.

| Argument | Description                                                                                |
| -------- | ------------------------------------------------------------------------------------------ |
| `alt`    | The text for screen readers and PDF accessibility. Default: the `title`, otherwise "Chart" |
| `class`  | A CSS class for the chart                                                                  |
| `style`  | Inline CSS added to the chart's own                                                        |

HTML previews return the placeholder as it is, and the dashboard draws it. An unknown chart type or argument, or a value out of range, fails with `template_runtime_error` and a message that names the argument.

#### Example [#example]

With monthly revenue in the data:

```json
{
  "currency": "EUR",
  "target": 180000,
  "monthly_revenue": [
    { "month": "2026-06-01", "revenue": 163400, "costs": 121000 },
    { "month": "2026-07-01", "revenue": 171900, "costs": 118500 },
    { "month": "2026-08-01", "revenue": 184250, "costs": 126300 }
  ]
}
```

this bar chart shows the revenue per month in euros against the target, with the current month highlighted and the months written as "Jun 2026":

```jinja
{{ render_chart("bar", monthly_revenue, label="month", value="revenue",
     labels=monthly_revenue | map(attribute="month") | map("format_date", "%b %Y") | list,
     title="Revenue", format="money", currency=currency, compact=true,
     highlight_last=true, colors=["#c7d2fe", "#4338ca"],
     reference=target, reference_label="Monthly target",
     width="170mm", height="70mm", alt="Revenue per month against the monthly target") }}
```

Two series as lines, and a donut from values written in the template:

```jinja
{{ monthly_revenue | render_chart("line", label="month", value=["revenue", "costs"], legend="top") }}
{{ render_chart("donut", labels=["Online", "Retail", "Partners"], values=[52, 31, 17], format="percent") }}
```

## Compatibility aliases [#compatibility-aliases]

Templates migrated from other systems often use these names. They behave like the filters above:

| Alias                                                                                            | Same as   | Notes                            |
| ------------------------------------------------------------------------------------------------ | --------- | -------------------------------- |
| `render_qrcode(style=…)`                                                                         | `qrcode`  | Accepts the same options         |
| `render_barcode(type, style, quiet_zone, font_size, text_distance, module_width, module_height)` | `barcode` | Same type names and option names |
| `render_table(table_class=…)`                                                                    | `table`   | `table_class` sets the CSS class |
| `json`                                                                                           | `tojson`  | Same output                      |

## Sandbox and limits [#sandbox-and-limits]

Templates run in a sandbox with no access to the filesystem, the network or the host: the only exception is `image_data_uri`, which goes through the policy-checked asset fetcher.

| Limit            | Value                                                                                                                                   |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Execution budget | Every operation consumes fuel, from 50,000 units on Free to 20 million on Scale. Exceeding it fails with `422 template_fuel_exhausted`. |
| Recursion depth  | 50, for nested macros, recursive loops and includes                                                                                     |
| Output size      | 50 MB of rendered HTML                                                                                                                  |
| Time             | Bounded by the render's sync timeout or async maximum duration                                                                          |

Most templates stay far below the execution budget. Deeply nested loops over thousands of rows are the usual cause of `template_fuel_exhausted`: precompute totals in your application, or split the document into several renders.

## Errors [#errors]

Template problems are reported with the line, column and an excerpt of the source, both in the error response and in the render's `error` field:

```json
{
  "type": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error",
  "title": "Template runtime error",
  "status": 422,
  "code": "template_runtime_error",
  "detail": "'dict object' has no attribute 'total' (line 12, column 7)",
  "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q",
  "request_id": "req_01J9ZM4T6W8Y0A2C4E6G8J0K2M",
  "doc_url": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error"
}
```

* `template_syntax_error`: the template can't be parsed, for example because of an unclosed block. Publishing a template validates the syntax, so these rarely reach production.
* `template_runtime_error`: something went wrong while rendering, such as an unknown filter, an unsupported operation between types, or an undefined value in strict mode.
* `template_fuel_exhausted`: the template exceeded its execution budget.

See [Errors](/docs/errors) for the full list.

## Differences from Python Jinja2 [#differences-from-python-jinja2]

The engine implements Jinja2 syntax and semantics and is checked against Python Jinja2 with a differential test suite, but it doesn't run Python. Keep these differences in mind when you port templates:

* Python methods are limited to the [compatibility list](#python-method-compatibility). A datetime's `.strftime()` or a list's `.append()` are not available; use the `strftime` filter and `namespace` instead.
* Values are immutable, so nothing can be modified in place.
* Booleans render as `true` and `false`.
* Templates are loaded from the version bundle only. There is no filesystem loader, and workspace partials are planned.
* The `%` string-formatting operator isn't supported. Use the `format` filter or `.format()`.
* Jinja2 extensions such as `{% trans %}` and custom Python filters aren't available. The [custom filters](#formatting-filters) cover the common document cases.
