Jinja2 Advanced
- Turn on
trim_blocksandlstrip_blocksand 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
{% import 'file.j2' as m %}— pulls in the file's macros as an object. The file's top-level output is discarded. Default is "context-free": the imported macros don't see the caller's variables unless you{% import 'file.j2' as m with context %}.{% include 'file.j2' %}— renders the file inline. Use for sharing ordinary template fragments (a standard header, a common footer).includedoes see the current context by default.
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 -%}
{%-strips whitespace before the tag, up to and including the preceding newline.-%}strips whitespace after the tag, including the following newline.
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 %}
trim_blocks— remove the first newline after a block tag.lstrip_blocks— strip leading whitespace from the start of a line up to a block tag.
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.
| Producing | Do this | Why |
|---|---|---|
| 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 strings | JSON 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:
equalto,ne,gt,ltin(Ansible-added):selectattr('zone', 'in', ['eu', 'us'])match/search(regex, Ansible-added)defined,none,string,number,mapping,sequence- No test (truthiness):
selectattr('active')picks items whoseactiveis truthy.
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:
- Paths are resolved relative to the role's
templates/directory, just like the outer template. - Use
indent()to align multi-line output with the surrounding block.indent(width, first=True)indents the first line too (default is false).
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.