Skip to content

7. Templating with Jinja2

Jinja2 is not a feature of Ansible that you use when you write templates. It is the layer through which every value in every task passes, and knowing that explains several behaviours that otherwise look arbitrary.

- ansible.builtin.copy:
dest: "/etc/app/{{ env }}.conf" # rendered
content: "port={{ app_port }}" # rendered
when: app_port is defined # ALSO Jinja — no braces
loop: "{{ app_configs }}" # rendered
when: ansible_facts['os_family'] == 'Debian' # correct
when: "{{ ansible_facts['os_family'] == 'Debian' }}" # wrong

when: is already a Jinja expression. Adding {{ }} wraps an expression in an expression; Ansible tolerates it and warns, and the result can be a string rather than a boolean, which then evaluates as true regardless of content.

The same applies to changed_when, failed_when, until, and assert’s that. Braces go in strings; conditions are bare.

{{ expression }} print the result
{% statement %} do something — if, for, set
{# comment #} not rendered
{# nginx.conf.j2 #}
worker_processes {{ nginx_workers | default('auto') }};
{% for backend in groups['appservers'] %}
server {{ hostvars[backend].ansible_facts.default_ipv4.address }}:8080;
{% endfor %}
{% if enable_tls | bool %}
ssl_certificate /etc/ssl/{{ inventory_hostname }}.crt;
ssl_certificate_key /etc/ssl/{{ inventory_hostname }}.key;
{% endif %}

Whitespace, and the setting you always want

Section titled “Whitespace, and the setting you always want”

By default, a {% for %} line leaves behind its newline, so a template full of loops produces a file full of blank lines.

{% for x in items %}
value {{ x }}
{% endfor %}
OUTPUT (default) OUTPUT (trim_blocks)
⏎ value a
value a value b
⏎ value c
value b
⏎
value c
⏎

Two fixes. The manual one is a hyphen, which strips whitespace on that side:

{%- for x in items -%}

The better one is to set it once, per template:

#jinja2: trim_blocks: True, lstrip_blocks: True

Or globally:

[defaults]
jinja2_native = false
trim_blocks = true

trim_blocks removes the newline after a block tag; lstrip_blocks removes leading whitespace before one. Together they let you indent your {% if %} blocks to match the file’s structure without that indentation appearing in the output. Turn both on and templates become dramatically easier to read.

Filters transform a value with |. There are hundreds; these are the ones that come up daily.

DEFAULTS AND SAFETY
default('x') value if undefined
default('x', true) value if undefined OR empty/false
default(omit) drop the argument entirely (ch. 6)
mandatory fail loudly if undefined
TYPES
bool 'false' string → False
int / float
string
list / dict2items / items2dict
STRINGS
upper / lower / capitalize / title
trim / replace('a','b')
regex_replace('^x','y')
regex_search('\d+')
split(',')
quote shell-safe — chapter 5
b64encode / b64decode
hash('sha256') / password_hash('sha512')
LISTS
join(', ')
first / last / length
unique / sort / reverse
min / max / sum
flatten
select / reject filter by a test
selectattr / rejectattr filter dicts by attribute
map('attribute','name')
zip / batch / slice
difference / intersect / union / symmetric_difference
DICTS
combine(other) merge — recursive=true for deep
dict2items / items2dict
json_query('...') JMESPath, needs community.general
FILES AND FORMATS
to_json / from_json
to_yaml / to_nice_yaml(indent=2)
basename / dirname / realpath / splitext
expanduser
NETWORK (ansible.utils / ansible.netcommon)
ipaddr('address') / ipaddr('net')
ipv4 / ipv6

default() — and know the two forms:

{{ port | default(8080) }} undefined → 8080
{{ port | default(8080, true) }} undefined OR empty OR 0 → 8080

The second form is what you usually mean when a variable might be set to an empty string.

mandatory — the opposite, and underused:

{{ database_password | mandatory }}

Fails the task with a clear message instead of rendering an empty string into a config file and producing a mystery three hours later.

combine() — merging dictionaries is how you build layered configuration:

vars:
app_defaults: {port: 8080, workers: 4, debug: false}
app_overrides: {workers: 16}
app_config: "{{ app_defaults | combine(app_overrides) }}"
# → {port: 8080, workers: 16, debug: false}
# for nested dicts:
merged: "{{ a | combine(b, recursive=true) }}"

selectattr / map — the pair that turns a list of dicts into what you need:

{# every host whose 'role' is 'primary' #}
{{ servers | selectattr('role', 'equalto', 'primary')
| map(attribute='name') | list }}
{# every non-loopback IPv4 address #}
{{ ansible_facts.all_ipv4_addresses | reject('equalto', '127.0.0.1') | list }}

Remember the | list. map and select return generators, and a generator rendered into a template prints something like <generator object at 0x7f...>. This is a memorable five minutes the first time.

to_nice_yaml / to_nice_json — when a template needs to emit structured data:

{{ app_config | to_nice_yaml(indent=2) }}

Far safer than building YAML by hand in a loop, because it escapes correctly.

Tests use is, not |, and read as English:

when: my_var is defined
when: my_var is not defined
when: my_var is none # explicitly null
when: result is failed
when: result is succeeded
when: result is changed
when: result is skipped
when: path is exists # on the CONTROL node
when: version is version('2.4', '>=')
when: name is match('^web')
when: name is search('prod')
when: item is string / is number / is iterable / is mapping

is defined versus | default() is worth being deliberate about. is defined branches; default() supplies a value. Use default() when there is a sensible fallback and is defined when absence means “do something different”.

And version() is the correct way to compare versions, because string comparison says 2.10 < 2.9:

when: ansible_facts['distribution_version'] is version('22.04', '>=')

This is the deepest gotcha in the tool and it deserves its own section.

Jinja templating produces text. When Ansible renders {{ some_number }}, the result is the string "8080", not the integer 8080.

vars:
port: 8080
port_copy: "{{ port }}" # this is now a STRING
tasks:
- ansible.builtin.debug:
msg: "{{ port_copy + 1 }}" # ERROR: can't add int to str

Most of the time Ansible converts back sensibly and you never notice. Where you do notice:

arithmetic "8080" + 1 → use | int
booleans "false" is truthy → use | bool
list membership "1" in [1,2,3] → false. | int
passing a dict to
a module becomes a string → see below

The systemic fix is native Jinja types:

[defaults]
jinja2_native = true

With this on, {{ port }} yields an integer, {{ my_list }} yields a real list, and arithmetic works. It is closer to what everyone expects.

It is not the default, and turning it on can change behaviour in existing playbooks — most visibly, values that used to render as strings now render as their native types, and templates that concatenated numbers into text may need | string. Turn it on for a new project; test carefully before turning it on for an old one.

vars:
secret: "{{ lookup('ansible.builtin.env', 'API_KEY') }}"
config: "{{ lookup('ansible.builtin.file', 'files/app.conf') }}"
hosts: "{{ lookup('ansible.builtin.ini', 'server section=web file=x.ini') }}"
pw: "{{ lookup('community.hashi_vault.hashi_vault', 'secret=x') }}"

Every lookup executes on the control node, never on the target. lookup('file', '/etc/hosts') reads the control node’s /etc/hosts. This is right — lookups are for pulling data into the play — and it is a regular source of confusion when someone expects to read a file from the machine being configured. For that, use ansible.builtin.slurp or ansible.builtin.command: cat, which run on the target.

The useful lookups:

file read a local file
env an environment variable
password generate and store a password
pipe run a local command
template render a template into a variable
vars indirect variable reference
first_found the first file that exists — great for
per-OS defaults
dict / items iterate a dictionary
url fetch a URL

first_found deserves a mention because it solves a real problem cleanly:

- ansible.builtin.include_vars: "{{ lookup('ansible.builtin.first_found', params) }}"
vars:
params:
files:
- "{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
paths: [vars]

Ubuntu 22 gets Ubuntu-22.yml if it exists, otherwise Ubuntu.yml, otherwise Debian.yml, otherwise default.yml. This is the standard way a role handles many operating systems and it is much better than a chain of when: conditions.

Jinja can express a great deal, and templates that lean on it heavily become unreadable and untestable. Three limits worth holding:

No logic in a template that belongs in a variable. If a template computes something, compute it in group_vars or a set_fact and pass in the answer. Then it is inspectable with debug.

No nested loops building structured data. If you are building YAML or JSON with {% for %}, build the data structure in variables and render it with to_nice_yaml.

No template over about a hundred lines of logic. At that point you have written a program, and chapter 5’s advice applies: write a filter plugin or a small module instead, where it can be tested.

  • Jinja renders every value in every task, not just template files.
  • when: and friends take bare expressions — no {{ }}. Braces belong in strings.
  • Turn on trim_blocks and lstrip_blocks. They remove the blank lines and stray indentation that make generated files ugly.
  • Learn default() (both forms), mandatory, combine(), selectattr/map, and to_nice_yaml and you have most of what you need.
  • map and select return generators — always finish with | list.
  • Tests use is, and is version('2.4','>=') is the only correct way to compare versions.
  • Templating produces strings. Use | int, | bool, | length — and consider jinja2_native = true on new projects, carefully on old ones.
  • Lookups run on the control node. To read a file on the target, use slurp.
  • first_found is the clean way to load per-OS variables, far better than a chain of when:.
  • Compute in variables, render in templates. A template doing real logic is a program in the wrong language.

Next: loops, conditionals and error handling — including the include-versus-import distinction that changes how tags and conditions behave.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source