Jinja2 Advanced

Macros, custom filters, whitespace control, JSON/YAML round-tripping, selectattr patterns, namespace for cross-block state, and lookup('template') — the parts of Jinja2 that make Ansible roles readable.

Rules that save hours
  • Turn on trim_blocks and lstrip_blocks and stop fighting whitespace by hand.
  • If you are about to paste YAML into a template, use |to_nice_yaml(indent=2). If you are about to paste YAML into a shell argument, use |quote.
  • If you are about to paste a value into JSON, use |tojson. Not "{{ x }}".
  • A template that needs three {% if %} blocks to stay readable wants a macro.
  • A filter that two roles need is a filter_plugins/ Python file, not a pile of chained built-ins.

Macros, import, include

A macro is a parameterised template snippet. Use one when the same block of output recurs with small variations — config stanzas per backend, systemd unit fragments, nginx location blocks.

{# roles/app/templates/_macros.j2 #}
{% macro location(path, upstream, timeout=30) -%}
location {{ path }} {
    proxy_pass http://{{ upstream }};
    proxy_read_timeout {{ timeout }}s;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
{%- endmacro %}
{# roles/app/templates/nginx-site.conf.j2 #}
{% import '_macros.j2' as m %}
server {
    listen 443 ssl;
    server_name {{ app_hostname }};

    {{ m.location('/api/',  'api_backend',  60) }}
    {{ m.location('/auth/', 'keycloak') }}
    {{ m.location('/',      'web_backend') }}
}

import vs include

call blocks

For macros that wrap content:

{% macro warn_box(title) -%}
<div class="warn"><strong>{{ title }}</strong>
{{ caller() }}
</div>
{%- endmacro %}

{% call warn_box('Deprecated') %}
This block is going away in v3.
{% endcall %}

The do extension

Vanilla Jinja2 is expression-oriented: you cannot mutate a list. The do extension adds a {% do %} statement that evaluates an expression for its side effects. Ansible exposes it as jinja2.ext.do; it is usually already loaded, but you can enable it explicitly:

# ansible.cfg
[defaults]
jinja2_extensions = jinja2.ext.do,jinja2.ext.loopcontrols
{% set users = [] %}
{% for row in raw_rows %}
  {% if row.active %}
    {% do users.append({'name': row.login, 'uid': row.id}) %}
  {% endif %}
{% endfor %}
users_active: {{ users | to_nice_yaml(indent=2) }}

In practice this is almost always a sign that the data preparation belongs in Python (a filter plugin) and the template should just format an already-prepared list. Use do sparingly.

Custom filters in filter_plugins/

When a Jinja expression grows past three chained filters, it is time to write Python. Filters live in filter_plugins/ inside the role (or at the top level of the project for global filters).

roles/app/
├── filter_plugins/
│   └── app_filters.py
├── tasks/
└── templates/
# roles/app/filter_plugins/app_filters.py
from urllib.parse import urlsplit


def backend_host(url):
    """Return hostname from a URL, stripping port and scheme."""
    return urlsplit(url).hostname or ""


def redact(val):
    """Mask a secret for human-visible output."""
    if not val:
        return ""
    if len(val) <= 4:
        return "*" * len(val)
    return val[:2] + "*" * (len(val) - 4) + val[-2:]


def group_by_first_letter(names):
    out = {}
    for n in names:
        out.setdefault(n[:1].upper(), []).append(n)
    return out


class FilterModule:
    def filters(self):
        return {
            "backend_host": backend_host,
            "redact": redact,
            "group_by_first_letter": group_by_first_letter,
        }
{{ backend_url | backend_host }}
{{ db_password | redact }}
{% for letter, names in users | map(attribute='name') | list | group_by_first_letter | dictsort %}
- {{ letter }}: {{ names | join(', ') }}
{% endfor %}

A filter must be a pure function, side-effect-free, deterministic. Ansible runs it on the controller, not on the target. Do not do I/O in a filter — that's what a lookup plugin is for.

Whitespace control

Jinja2's default output keeps the newlines around tags. Without tuning you get config files with blank lines everywhere.

Per-tag trim: {%- and -%}

{%- for user in users -%}
{{ user.name }}
{%- endfor -%}

Environment-level: trim_blocks and lstrip_blocks

Set these once and stop littering - characters. In an Ansible template, add the line-directive at the top:

#jinja2: trim_blocks: True, lstrip_blocks: True
{% for u in users %}
    user {{ u.name }};
{% endfor %}

With both on, the template above renders exactly what you expect and you can indent the {% for %} to match your config file's structure.

Quoting: tojson, quote, to_nice_yaml

The question every Ansible author asks: "do I need quotes around {{ x }}?" The correct answer depends on the target syntax.

ProducingDo thisWhy
JSON{{ x | tojson }}Handles strings, numbers, lists, dicts, and embedded quotes correctly.
YAML (config file){{ x | to_nice_yaml(indent=2) }}Block style, indented; good for a long dict/list.
YAML one-liner value{{ x | to_json }} (valid YAML 1.2) or "{{ x }}" for plain stringsJSON is valid YAML — free quoting.
Shell argument{{ x | quote }}Single-quotes and escapes existing single quotes — safe for bash -c.
Python string literal{{ x | tojson }}Python parses JSON strings just fine.
INI value with spaces"{{ x }}" (plain double quotes; assert no " inside)INI has no escaping rules; the assert is your guard.
{# Bad: will explode if password contains a quote or backslash #}
password: "{{ db_password }}"

{# Good: tojson handles the full range of string content #}
password: {{ db_password | tojson }}

{# A bash wrapper script #}
#!/bin/bash
exec /opt/app/bin/run \
  --name {{ app_name | quote }} \
  --env {{ env_json | tojson | quote }}

YAML / JSON round-trips

Ansible ships to_json / from_json / to_yaml / from_yaml, plus to_nice_* variants that pretty-print. Use them to shuttle data between string and structured forms.

{# Parse a JSON string read from a file #}
{% set manifest = lookup('file', '/etc/app/manifest.json') | from_json %}
version: {{ manifest.version }}

{# Render a structured value as YAML for a config file #}
{{ app_config | to_nice_yaml(indent=2, width=80) }}

{# Round-trip to sort keys for a stable diff #}
{{ features | to_json | from_json | dict2items | sort(attribute='key') | items2dict | to_nice_yaml }}
to_nice_yaml is a thin wrapper over PyYAML. It does not know about your template's indentation: always start it at the far-left column and let Ansible/YAML parse the result. Indenting inside a block list means {% filter indent(4) %}...{% endfilter %}.
services:
  app:
    image: myapp:{{ app_version }}
    environment:
{% filter indent(width=6, first=True) %}
{{ app_env | to_nice_yaml(indent=2) }}
{% endfilter %}

selectattr / rejectattr / map patterns

Filters for working with lists of dicts. The common trio:

{# All active users' emails #}
{{ users | selectattr('active') | map(attribute='email') | list }}

{# All hosts NOT in maintenance #}
{{ inventory_hostname_short if not hostvars[inventory_hostname].maintenance else '' }}
{{ groups['web'] | reject('in', groups['maintenance']) | list }}

{# Admins only, by role #}
{{ users | selectattr('role', 'equalto', 'admin') | list }}

{# Users whose names match a regex #}
{{ users | selectattr('login', 'match', '^svc_') | map(attribute='login') | list }}

{# Chain the result into a comma-separated string #}
{{ users | selectattr('active') | map(attribute='email') | join(', ') }}

Handy tests used with selectattr / rejectattr:

dict2items / items2dict

Jinja treats dicts as unordered; dict2items gives you a list of {key:..., value:...} you can loop over:

{% for item in users_by_id | dict2items | sort(attribute='key') %}
id={{ item.key }} name={{ item.value.name }}
{% endfor %}

Namespaces and cross-block state

Variables assigned with {% set %} inside a {% for %} block are local to that iteration. This trips people up every time:

{# Does NOT work — found stays false outside the loop #}
{% set found = false %}
{% for u in users %}
  {% if u.login == target %}
    {% set found = true %}
  {% endif %}
{% endfor %}
{% if found %}...{% endif %}     {# always takes the else branch #}

Fix: a namespace object holds mutable state that survives the loop.

{% set ns = namespace(found=false, count=0) %}
{% for u in users %}
  {% if u.active %}
    {% set ns.count = ns.count + 1 %}
    {% if u.login == target %}
      {% set ns.found = true %}
    {% endif %}
  {% endif %}
{% endfor %}
{% if ns.found %}User {{ target }} exists and there are {{ ns.count }} active users.{% endif %}

loop.index, loop.first, loop.last, and loop.previtem / loop.nextitem (2.12+) cover most "where am I in the loop" questions without needing a namespace.

lookup('template') inside a template

Sometimes you need one template to include the rendered output of another — an nginx config that includes a generated SSL stanza, a systemd drop-in that imports a shared fragment.

{# roles/web/templates/nginx.conf.j2 #}
worker_processes {{ ansible_processor_vcpus }};

http {
{{ lookup('template', 'http-common.conf.j2') }}

    server {
        listen 443 ssl;
{{ lookup('template', 'tls-block.conf.j2') | indent(8, first=True) }}
        ...
    }
}

lookup('template', path) renders the nested template in the current variable context and returns a string. Two practical notes:

When you need loops across files: prefer a macro (compile-time) over lookup('template') (runtime-ish on the controller). Macros are faster to render and cheaper to reason about.

See also: Jinja2 (index), Handlers & Templates, YAML Pitfalls.