7. Templating with Jinja2
Ansible · 7. Templating with Jinja2Jinja2 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 }}" # renderedThe when: clause has no braces
Section titled “The when: clause has no braces”when: ansible_facts['os_family'] == 'Debian' # correctwhen: "{{ ansible_facts['os_family'] == 'Debian' }}" # wrongwhen: 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.
The three delimiters
Section titled “The three delimiters” {{ 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: TrueOr globally:
[defaults]jinja2_native = falsetrim_blocks = truetrim_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.
The filters worth memorising
Section titled “The filters worth memorising”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 / ipv6The five that do the most work
Section titled “The five that do the most work”default() — and know the two forms:
{{ port | default(8080) }} undefined → 8080{{ port | default(8080, true) }} undefined OR empty OR 0 → 8080The 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 definedwhen: my_var is not definedwhen: my_var is none # explicitly nullwhen: result is failedwhen: result is succeededwhen: result is changedwhen: result is skippedwhen: path is exists # on the CONTROL nodewhen: version is version('2.4', '>=')when: name is match('^web')when: name is search('prod')when: item is string / is number / is iterable / is mappingis 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', '>=')Everything is a string
Section titled “Everything is a string”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 strMost 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 belowThe systemic fix is native Jinja types:
[defaults]jinja2_native = trueWith 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.
Lookups run on the control node
Section titled “Lookups run on the control node”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 URLfirst_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.
Where to stop
Section titled “Where to stop”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.
What to take from this chapter
Section titled “What to take from this chapter”- 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_blocksandlstrip_blocks. They remove the blank lines and stray indentation that make generated files ugly. - Learn
default()(both forms),mandatory,combine(),selectattr/map, andto_nice_yamland you have most of what you need. mapandselectreturn generators — always finish with| list.- Tests use
is, andis version('2.4','>=')is the only correct way to compare versions. - Templating produces strings. Use
| int,| bool,| length— and considerjinja2_native = trueon new projects, carefully on old ones. - Lookups run on the control node. To read a file on the target, use
slurp. first_foundis the clean way to load per-OS variables, far better than a chain ofwhen:.- 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.