Ansible Lesson 9 of 42

Ansible Jinja2 Templating, In Depth: the template Module, Filters, Tests & Lookups

Sooner or later every configuration-management job comes down to the same thing: writing a file onto a server whose contents depend on that server. The Nginx worker_processes should match the box’s CPU count; the database connection string differs per environment; the list of upstream backends is whatever happens to be in the web group today. You could hand-edit each file, but then you are back to the click-ops museum that Ansible exists to abolish. The answer is a template: a text file with holes in it, where Ansible fills the holes from variables and facts at run time and writes the rendered result to the target. The templating engine Ansible uses is Jinja2, the same engine behind Flask and a dozen other Python tools, and it is woven through far more of Ansible than just the template module — every {{ }} in a when:, a vars: value, a loop:, or a set_fact is Jinja2 evaluating against your variables.

This lesson is the deep dive on Jinja2 as Ansible uses it. By the end you will know the ansible.builtin.template module option by option (and exactly when to reach for it over copy); the three Jinja2 delimiters — {{ }} for expressions, {% %} for statements, {# #} for comments — and what each is for; the control structures (if/elif/else, for loops with loop.index and friends, set); the filter catalogue that turns raw data into the shape you need, presented as a reference table you will come back to; the tests that answer yes/no questions (is defined, is match, is in); the lookup plugins that pull data in from files, the environment, and password stores; whitespace control with the - modifier (the thing that makes generated files actually look right); how variable scoping behaves inside templates and loops; and the ansible_managed header that stops a colleague hand-editing a file Ansible owns. Everything targets current Ansible (ansible-core 2.17+ / Ansible 10+, 2026) and uses FQCNansible.builtin.template — throughout. We finish with a complete, free, local lab that renders a real config file end to end.

Learning objectives

After working through this lesson you will be able to:

Prerequisites & where this fits

You should already be comfortable with the building blocks from earlier in the Ansible track: writing a playbook with plays and tasks, defining and referencing variables, gathering and using facts (ansible_facts / the ansible_* magic variables), and capturing task output with register — all of which feed the templates you are about to write. You will also lean on conditionals and loops, since the same when: expressions and loop: constructs appear inside templates as {% if %} and {% for %}. If you have read Ansible Variables & Facts, In Depth and Ansible Conditionals, Loops, Handlers & Tags, you have exactly the foundation this lesson assumes; if not, skim them first. This is the Templating stop in the Intermediate tier of the Ansible Zero-to-Hero ladder, and it is one of the most heavily-weighted skills on the RHCE EX294 exam — templating a service’s config from facts is a near-certain task. All you need installed is ansible-core; the lab runs entirely against localhost and costs nothing.

Core concepts

Three ideas carry the whole lesson, so fix them first.

Jinja2 is a text-templating engine, not a programming language. A Jinja2 template is plain text with embedded placeholders and logic. When rendered, every placeholder is replaced by a value and every block of logic is executed to decide what text appears. It is expression-oriented: {{ ... }} evaluates an expression and substitutes the result; it is not a general-purpose language with file I/O or arbitrary side effects (by design — that keeps templates safe and predictable). Ansible adds its own filters, tests, and lookups on top of stock Jinja2, so some things you will use (ansible.builtin.to_nice_yaml, the combine filter, the file lookup) are Ansible extensions, not core Jinja2.

Templating happens in two places, with the same syntax. Inside a play, every value Ansible reads is “templated” — name: "Deploy {{ app_name }}", when: ansible_facts.os_family == "RedHat", loop: "{{ users }}" all run through Jinja2. Separately, the template module renders a whole .j2 file and writes it to a managed node. The syntax ({{ }}, {% %}, filters, tests) is identical in both; the difference is only where the rendered output goes — into a task argument versus into a file on the target.

Where rendering runs: the control node. This trips people up. The template module reads the .j2 from the control node, renders it on the control node using that host’s facts and variables, then copies the finished file to the managed node. Facts like ansible_facts.processor_vcpus are available because Ansible has already gathered them into the host’s variable namespace; the template never executes on the target. (Contrast ansible.builtin.copy, which just transfers a static file with no rendering.)

A few terms you will meet repeatedly:

Term Meaning
Expression {{ }} Evaluates and prints a value (a variable, a literal, a filter chain, a function call).
Statement {% %} Control logic that produces no direct output: if, for, set, include, macro.
Comment {# #} Text removed from the output entirely; never rendered.
Filter A transformation applied with |: value | filter. Input on the left, result on the right.
Test A yes/no question asked with is: var is defined, n is even.
Lookup A plugin that pulls external data in (file contents, env vars, a generated password) at template/parse time, running on the control node.
ansible_managed A special variable holding a “do not edit by hand” banner you put at the top of generated files.

The ansible.builtin.template module: every option

The template module is copy’s smarter sibling: it takes a Jinja2 source, renders it, and lays the result down on the target with the same file-attribute controls copy has (mode, owner, backup, validate). Because it shares the file-attribute machinery, almost everything you know about copy applies — plus the rendering.

A minimal task:

- name: Render the Nginx site config
  ansible.builtin.template:
    src: nginx-site.conf.j2          # a Jinja2 file in the role's templates/ dir
    dest: /etc/nginx/conf.d/site.conf
    owner: root
    group: root
    mode: "0644"
    backup: true
    validate: "nginx -t -c %s"
  notify: reload nginx

Here is the full option matrix. The most important ones for templating specifically are src, dest, validate, backup, and the whitespace pair trim_blocks/lstrip_blocks.

Option What it does Choices / default When to use it / gotcha
src Path to the Jinja2 template on the control node. Path; required. In a role, a bare filename resolves against the role’s templates/ directory automatically — src: app.conf.j2 finds roles/x/templates/app.conf.j2. Outside a role it is relative to the playbook. Convention: name templates *.j2.
dest Absolute path of the rendered file on the managed node. Path; required. If dest ends in / it is treated as a directory and the source basename (minus .j2? — no, the full basename) is used. Be explicit; give the full target filename.
mode Permissions of the destination file. Octal string ("0644") or symbolic (u=rw,g=r,o=r); default = umask-derived for new files, preserved for existing. Always quote octal ("0644" not 0644) or YAML reads it as a decimal integer and you get wrong permissions. Use this for secrets: render a creds file mode: "0600".
owner / group Ownership of the destination. Username / group name; default = the connection/become user. Requires privilege to chown — usually pair with become: true.
validate Run a command against the rendered temp file before moving it into place; if it fails (non-zero), the task fails and dest is untouched. Shell command containing %s (the temp path); default none. The single best feature for config safety — validate: "nginx -t -c %s", validate: "visudo -cf %s", validate: "sshd -t -f %s". %s is mandatory and is where the temp file path is substituted. Prevents shipping a broken config.
backup Before overwriting, copy the existing dest to a timestamped backup. true / false; default false. Turn on for anything hand-editable or risky so you can roll back; the backup path is returned in the result (backup_file).
trim_blocks Remove the first newline after a block tag (%}). true / false; default true in Ansible (Jinja2’s own default is false). The main reason generated files do not have a blank line after every {% if %}/{% endif %}. Usually leave it on.
lstrip_blocks Strip leading whitespace (spaces/tabs) from the start of a line up to a block tag. true / false; default false. Turn on so you can indent your {% %} logic to match the file’s structure without that indentation leaking into the output. Commonly enabled alongside trim_blocks.
newline_sequence The line ending written to dest. \n (default), \r, \r\n. Set \r\n when templating a file consumed by Windows tools.
block_start_string / block_end_string Override the statement delimiters ({% / %}). Strings; defaults {% / %}. Rare — use only when the file’s own syntax clashes with Jinja2 (e.g. templating a file that itself contains {% %}).
variable_start_string / variable_end_string Override the expression delimiters ({{ / }}). Strings; defaults {{ / }}. The classic case: templating a file full of literal {{ }} (another Jinja2 template, a Vue/Angular file). Set these to [% / %] so your placeholders don’t collide.
comment_start_string / comment_end_string Override the comment delimiters ({# / #}). Strings; defaults {# / #}. Rare; same idea as above.
force Overwrite dest even if it already differs. true / false; default true. force: false means “only create if absent” — write a file once and never touch it again.
output_encoding Encoding of the rendered file. Default utf-8. Change only for legacy systems needing a different charset.
follow Follow symlinks at dest when setting attributes. true / false; default false. If dest is a symlink, decide whether attributes apply to the link or its target.
unsafe_writes Allow a non-atomic write if the atomic rename fails. true / false; default false. Last resort for odd filesystems (some bind mounts/containers) where the safe atomic replace can’t work. Leave off normally.
attributes Set filesystem attributes (the lsattr/chattr set, e.g. i immutable). String; default none. Niche.
lstrip_blocks, trim_blocks (recap) (see above — the whitespace pair) These are the two you will actually tune; the rest of the delimiter overrides are situational.

template is idempotent: it renders to a temp file, compares the result with the current dest, and reports changed only if they differ — so re-running a play with unchanged variables is a no-op (ok, not changed). Two related modules round out the family: copy transfers a static file (no rendering — use it when there is nothing to substitute), and ansible.builtin.blockinfile/lineinfile edit part of an existing file rather than owning the whole thing. The rule of thumb: if Ansible owns the entire file, use template; if you must inject a managed stanza into a file something else owns, use blockinfile.

Expressions, statements & comments: the three delimiters

Every Jinja2 construct is one of three things, distinguished by its delimiter.

Expressions — {{ ... }} — print a value. Whatever the expression evaluates to is rendered into the text at that spot. This is by far the most common construct.

server_name {{ inventory_hostname }};
worker_processes {{ ansible_facts.processor_vcpus }};
max_clients = {{ (ansible_facts.processor_vcpus | int) * 256 }};
greeting = "{{ app_name | default('myapp') | upper }}"

An expression can be a variable (inventory_hostname), a literal ("hello", 42, true, a [list] or {dict}), arithmetic, a filter chain, a test result, or a lookup. It must produce something printable.

Statements — {% ... %} — do logic, print nothing themselves. These are the control structures: if, for, set, include, import, macro, filter, with. They wrap or generate text but emit no output of their own (only the text inside them is emitted).

{% if enable_tls %}
listen 443 ssl;
{% endif %}
{% for host in groups['web'] %}
server {{ hostvars[host].ansible_host }}:8080;
{% endfor %}

Comments — {# ... #} — are deleted. Everything between {# and #} is removed from the rendered output. Use them to annotate the template (notes for the next engineer) without those notes appearing in the file the server reads. If you want a comment in the output file, write a literal # (or ;, or whatever that file’s comment character is) outside the delimiters.

{# This block only renders on RedHat-family hosts — see roles/web/defaults #}
# Managed by Ansible — generated {{ ansible_managed }}

One more delimiter behaviour worth knowing: {{ '{{' }} (or backslashes, or a {% raw %}{% endraw %} block) is how you emit a literal {{ into output when you are templating something that itself uses Jinja-like syntax — though more often you would just override variable_start_string on the template task as shown above.

Control structures: if/elif/else, for, set

Conditionals — if / elif / else

The structure mirrors Python and the same boolean logic you use in when:. Conditions can test variables, facts, registered results, comparisons, membership, and tests.

{% if ansible_facts.os_family == "RedHat" %}
include /etc/nginx/conf.d/*.conf;
{% elif ansible_facts.os_family == "Debian" %}
include /etc/nginx/sites-enabled/*;
{% else %}
# unknown OS family: {{ ansible_facts.os_family }}
{% endif %}

You can combine conditions with and, or, not, group with parentheses, test membership with in, and use the is test keyword ({% if backends is defined and backends | length > 0 %}). There is also an inline conditional expression for use inside {{ }}{{ "ssl" if enable_tls else "plain" }} — which is the Jinja2 equivalent of a ternary and pairs naturally with the ternary filter discussed later.

Loops — for

for iterates lists, dictionaries, ranges, and the output of filters. Inside the loop, the special loop object exposes where you are in the iteration:

upstream backend {
{% for host in groups['web'] %}
    server {{ hostvars[host].ansible_default_ipv4.address }}:8080  {{ "backup" if not loop.first else "" }};  # {{ loop.index }}/{{ loop.length }}
{% endfor %}
}

The loop variables you will use:

loop attribute Value
loop.index Current iteration, 1-based (1, 2, 3…).
loop.index0 Current iteration, 0-based (0, 1, 2…).
loop.revindex / loop.revindex0 Iterations remaining (1-based / 0-based).
loop.first true on the first iteration.
loop.last true on the last iteration.
loop.length Total number of items.
loop.previtem / loop.nextitem The previous / next item (undefined at the ends).
loop.cycle('a','b') Returns the arguments in rotation each pass — handy for alternating CSS classes or odd/even.

Loops take an else clause that runs only when the iterable was empty — perfect for “no backends configured” fallbacks:

{% for b in backends %}
server {{ b }};
{% else %}
# no backends defined
{% endfor %}

To iterate a dictionary, loop its .items() ({% for key, value in mydict.items() %}) or, in the Ansible idiom, run it through the dict2items filter first (covered below). You can also add an inline filter on the loop target ({% for u in users | sort(attribute='name') %}) and an inline if to filter elements ({% for u in users if u.active %}).

set — variables inside a template

{% set %} assigns a variable within the template’s render scope. Use it to compute something once and reuse it, or to build up a value.

{% set pool_size = (ansible_facts.processor_vcpus | int) * 4 %}
{% set listen_addr = bind_address | default('0.0.0.0') %}
pool_size = {{ pool_size }}
listen = {{ listen_addr }}:{{ app_port | default(8080) }}

There is a critical scoping caveat with set and loops, important enough that it gets its own section below.

Whitespace control: the - modifier (and why your files look wrong without it)

This is the single most-asked “why does my generated file have blank lines everywhere?” question. Jinja2, by default, keeps the newlines and indentation around your {% %} tags, so a block like:

{% for s in services %}
{{ s }}
{% endfor %}

can render with stray blank lines between items unless whitespace is managed. There are three levers, and you will use all three.

1. trim_blocks (on by default in Ansible) removes the single newline immediately after a %}. This is why the lines containing {% for %} and {% endfor %} don’t each leave an empty line.

2. lstrip_blocks (off by default) strips the whitespace from the start of a line up to a {%. Turn it on so you can indent your logic for readability without that indentation appearing in the output:

- ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/app.conf
    trim_blocks: true
    lstrip_blocks: true

3. The - modifier is the manual, per-tag control and the one to reach for when the block settings aren’t enough. A - immediately inside a delimiter strips whitespace (including newlines) on that side:

servers:
{% for s in servers -%}
  - {{ s }}
{% endfor -%}

The -%} after each tag pulls the following whitespace up so the list items sit flush. Mastering the - is what separates a clean, diff-able generated file from one littered with phantom blank lines. The practical workflow: enable trim_blocks + lstrip_blocks on the task, then sprinkle - only where you still see unwanted whitespace. Always check the rendered result (run with --diff, or use the ansible.builtin.template lookup / debug in the lab) rather than eyeballing the .j2.

The filter catalogue

Filters are where raw variables become the exact shape a config file needs. Apply one with the pipe — value | filter — and chain them left to right: {{ users | map(attribute='name') | join(', ') }} takes a list of user dicts, plucks each name, and joins them with commas. Filters never mutate the input; they return a new value.

Some filters are stock Jinja2; many are Ansible extensions (in the ansible.builtin namespace, though you almost always call them by their short name). Here is the working catalogue, grouped by job. This is the table to bookmark.

Filter What it does Example Result
default(x) Substitute x if the variable is undefined. {{ port | default(8080) }} 8080 if port unset
default(x, true) Substitute x if undefined or empty/false (the boolean=true form). {{ name | default('anon', true) }} anon even if name == ""
mandatory Fail with an error if the variable is undefined (no silent default). {{ db_password | mandatory }} error if unset
to_json / to_nice_json Serialise to JSON (compact / indented). {{ data | to_nice_json }} pretty JSON
to_yaml / to_nice_yaml Serialise to YAML (compact / human-readable, indent=2). {{ data | to_nice_yaml }} pretty YAML
from_json Parse a JSON string into data. {{ result.stdout | from_json }} dict/list
from_yaml / from_yaml_all Parse a YAML string (single / multi-document). {{ blob | from_yaml }} dict/list
join(sep) Join a list into a string. {{ pkgs | join(', ') }} nginx, git, curl
split(sep) (Python str method via Jinja) split a string into a list. {{ "a,b,c".split(',') }} ['a','b','c']
map(attribute=...) Pluck an attribute/key from each item. {{ users | map(attribute='name') | list }} list of names
map('filter') Apply a filter to every element. {{ names | map('upper') | list }} uppercased list
select / reject Keep / drop items passing a test. {{ nums | select('even') | list }} even numbers
selectattr / rejectattr Keep / drop items by an attribute test. {{ users | selectattr('active') | list }} active users
selectattr(k,'eq',v) Filter by attribute comparison. {{ hosts | selectattr('os','eq','RedHat') | list }} matching hosts
unique Remove duplicates. {{ [1,1,2,3] | unique | list }} [1,2,3]
sort / sort(attribute=...) Sort a list (optionally by key). {{ users | sort(attribute='name') }} sorted
flatten Flatten nested lists. {{ [[1,2],[3]] | flatten }} [1,2,3]
dict2items Convert a dict to a list of {key, value} for looping. {{ caps | dict2items }} [{key:.., value:..}, …]
items2dict Inverse: list of {key,value} back to a dict. {{ pairs | items2dict }} dict
combine(other) Merge two dicts (right wins; recursive=true for deep merge). {{ base | combine(override) }} merged dict
zip / zip_longest Pair up two lists. {{ a | zip(b) | list }} list of pairs
length (count) Number of items / characters. {{ groups['web'] | length }} 3
min / max / sum Aggregate a numeric list. {{ [3,1,2] | max }} 3
ternary(a, b) Return a if the input is truthy, else b (3-arg form adds a null case). {{ (env=='prod') | ternary('warn','debug') }} warn/debug
bool Coerce to a real boolean. {{ "yes" | bool }} true
int / float Coerce to number. {{ "256" | int }} 256
string Coerce to string. {{ 8080 | string }} "8080"
upper / lower / capitalize / title Case transforms. {{ "myApp" | upper }} MYAPP
trim / replace(a,b) Strip whitespace / substring replace. {{ s | replace('-', '_') }} replaced
regex_replace(p, r) Regex substitution. {{ path | regex_replace('^/srv/', '/data/') }} rewritten
regex_search(p) Return the first regex match (or empty). {{ ver | regex_search('[0-9]+\\.[0-9]+') }} 3.11
regex_findall(p) All matches as a list. {{ log | regex_findall('ERROR') }} list
regex_escape Escape a string for safe use in a regex. {{ name | regex_escape }} escaped
b64encode / b64decode Base64 encode/decode (e.g. for k8s secrets). {{ token | b64encode }} base64
hash('sha256') / checksum Hash a string. {{ pw | hash('sha512') }} digest
password_hash('sha512') Produce a crypt hash for /etc/shadow (use with the user module). {{ pw | password_hash('sha512') }} $6$…
quote Shell-quote a string safely. echo {{ msg | quote }} quoted
basename / dirname Path components. {{ '/a/b/c.txt' | basename }} c.txt
realpath / expanduser Path normalisation. {{ '~/x' | expanduser }} /home/u/x
ipaddr / ipv4 / ipaddr('network') IP/CIDR maths (ansible.utils/ansible.netcommon). {{ '10.0.0.5/24' | ansible.utils.ipaddr('network') }} 10.0.0.0
to_datetime / strftime Date handling. {{ '%Y-%m-%d' | strftime }} today’s date
human_readable / human_to_bytes Size formatting both ways. {{ 1048576 | human_readable }} 1.00 MB
urlsplit('hostname') Pull a component out of a URL. {{ url | urlsplit('hostname') }} host
comment Wrap text in a language’s comment markers + a box. {{ "owned by ansible" | comment }} #\n# owned…\n#

A few notes that save real debugging time:

Tests: yes/no questions with is

Where filters transform, tests ask a question and return true/false. The keyword is is (negate with is not). Tests are what you put in {% if %} and in when:.

{% if db_password is defined and db_password is not none %}
password = {{ db_password }}
{% endif %}
{% if backends is iterable and backends | length > 0 %} ... {% endif %}

The tests you will actually use:

Test True when… Example
is defined / is undefined The variable is / isn’t set. var is defined
is none The value is null/None. x is none
is truthy / is falsy Evaluates truthy / falsy (2.10+). flag is truthy
is match(pattern) String matches the regex from the start. host is match('^web')
is search(pattern) Regex matches anywhere in the string. path is search('logs')
is in [...] Value is a member of the collection. env is in ['dev','prod']
is sameas other Same object identity (e.g. is sameas true). x is sameas true
is string / is number / is mapping / is sequence / is iterable Type checks. v is mapping
is even / is odd / is divisibleby(n) Number tests. i is even
is version(v, op) Version comparison (Ansible). ansible_facts.distribution_version is version('9', '>=')
is file / is directory / is exists Path tests on the control node (Ansible). path is exists
is failed / is succeeded / is changed / is skipped On a registered result. result is failed
is subset / is superset Set relationship between two lists. a is subset(b)

The registered-result tests (is failed, is changed, is succeeded, is skipped) are heavily used in when: after a task — when: pkg_install is changed — and read far better than poking at .rc or .failed by hand. is match vs is search is the classic gotcha: match anchors at the beginning, search does not.

Lookups: pulling external data in

A lookup runs on the control node and pulls data into your play from outside — a file’s contents, an environment variable, the output of a command, a generated password. You call one with the lookup('plugin', 'args', ...) function inside {{ }} (or in a task’s vars:).

vars:
  ssh_pubkey: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
  home_dir:   "{{ lookup('ansible.builtin.env', 'HOME') }}"
  build_no:   "{{ lookup('ansible.builtin.pipe', 'git rev-parse --short HEAD') }}"

The lookups every Ansible user needs:

Lookup Returns Example
file The contents of a file on the control node. lookup('ansible.builtin.file', '/path/key.pub')
env The value of an environment variable. lookup('ansible.builtin.env', 'HOME')
template A .j2 rendered to a string (not a file) — embed a sub-template inside a value. lookup('ansible.builtin.template', 'snippet.j2')
pipe The stdout of a command run on the control node. lookup('ansible.builtin.pipe', 'date +%s')
password A password, generating and persisting it to the given file if absent. lookup('ansible.builtin.password', 'creds/db.txt length=20')
first_found The first path that exists from a list — the basis of OS-specific var/file selection. lookup('ansible.builtin.first_found', params)
vars The value of a variable named dynamically (a var whose name is itself in a variable). lookup('ansible.builtin.vars', 'app_' + env)
csvfile A field from a CSV keyed by a column. lookup('ansible.builtin.csvfile', 'web file=hosts.csv col=1')
ini A value from an INI/properties file. lookup('ansible.builtin.ini', 'port section=server file=app.ini')
url The body fetched from a URL. lookup('ansible.builtin.url', 'https://…')
dict Iterate a dict as {key, value} (in loop). loop: "{{ lookup('ansible.builtin.dict', mydict) }}"
fileglob A list of files matching a glob on the control node. lookup('ansible.builtin.fileglob', 'files/*.conf')

Two essentials:

lookup vs query (and wantlist). lookup() by default returns a single comma-joined string when a plugin yields multiple results — which is wrong for loops. Use query() (alias q()) instead when you want a real list, or pass wantlist=true to lookup. The rule: loop: "{{ query('ansible.builtin.fileglob', 'files/*') }}", not lookup(...).

first_found for OS-specific files is the idiom for “use the Debian template if on Debian, else the RedHat one, else a default”:

- name: Ship the right service file
  ansible.builtin.template:
    src: "{{ lookup('ansible.builtin.first_found', params) }}"
    dest: /etc/myapp/myapp.conf
  vars:
    params:
      files:
        - "myapp.{{ ansible_facts.distribution }}.conf.j2"
        - "myapp.{{ ansible_facts.os_family }}.conf.j2"
        - "myapp.default.conf.j2"
      paths:
        - templates

A timing note: lookups run at templating time on the control node, every time the value is evaluated. They do not run on the managed node. So lookup('file', ...) reads a file on your machine, not the target — to read a file on the target you would use slurp or fetch, not a lookup. Wrap lookups that touch secrets with no_log: true on the task so they don’t print.

Variable scoping inside templates (the loop set trap)

Templates see the host’s full variable namespace — every var, fact, registered result, and magic variable — through ordinary Jinja2 references. {% set %} adds template-local variables on top. The trap is block scoping inside for loops: a variable set inside a loop does not persist to after the loop, because each iteration has its own scope.

{% set found = false %}
{% for h in groups['web'] %}
  {% if hostvars[h].role == 'primary' %}
    {% set found = true %}   {# this assignment is LOST after the loop #}
  {% endif %}
{% endfor %}
{# here, `found` is still false! #}

The Jinja2-native fixes are the namespace object ({% set ns = namespace(found=false) %} then {% set ns.found = true %} inside the loop — attributes on a namespace do survive), or the loop.last accumulation pattern. But in Ansible the cleaner answer is usually to do the computation in the play, not the template: compute the value with set_fact and a filter (set_fact: has_primary={{ groups['web'] | map('extract', hostvars, 'role') | select('eq','primary') | list | length > 0 }}) and reference the resulting fact in the template. Keep templates declarative; push logic up into the play where it is testable and visible in --check/--diff. Magic variables like inventory_hostname, hostvars, groups, group_names, and ansible_play_hosts are always available inside templates and are the main way one host’s template reaches another host’s facts.

The ansible_managed header

A file Ansible owns should say so, loudly, so a colleague doesn’t hand-edit it only to have the next play silently overwrite their change. The convention is to stamp generated files with the ansible_managed variable at the top:

# {{ ansible_managed }}
# {{ ansible_managed }} — generated for {{ inventory_hostname }} on {{ ansible_date_time.iso8601 | default('n/a') }}

By default ansible_managed renders to the string Ansible managed. You can make it far more useful by setting ansible_managed in ansible.cfg to include the source path and a timestamp:

[defaults]
ansible_managed = Ansible managed: {file} on {host} — last changed {uid} {date}

The tokens ({file}, {host}, {uid}, {date}) are expanded by Ansible when the template is rendered, producing a banner like Ansible managed: roles/web/templates/site.conf.j2 on web01 — last changed vinod 2026/06/15. One caution from the docs: because {date} changes every run, putting a timestamp in ansible_managed makes the rendered file differ each time, so the template task reports changed on every run even when nothing meaningful changed. If idempotent change-reporting matters (it usually does, for clean CI output), keep ansible_managed static (drop {date}) — the file itself is your audit trail via --diff and backups.

Ansible Jinja2 templating: how the template module renders on the control node

The diagram traces a render: variables, facts, and lookup results on the control node flow into the Jinja2 engine, which processes {{ expressions }}, {% statements %}, filter chains, and tests against the .j2 source, then validate checks the rendered temp file before template lays the finished, mode-set file onto the managed node.

Hands-on lab

We will render a real-looking application config from variables and facts — entirely free and local, targeting localhost with the local connection, so there is no cloud, no remote host, and no cost. You only need ansible-core installed (ansible --version).

1. Set up a working directory. Create jinja-lab/ with three files: a playbook, a template, and an ansible.cfg.

jinja-lab/ansible.cfg:

[defaults]
inventory = localhost,
ansible_managed = Ansible managed: {file} on {host}
retry_files_enabled = false

jinja-lab/templates/app.conf.j2:

# {{ ansible_managed }}
[server]
hostname = {{ inventory_hostname }}
# worker count = vCPUs * 2, falling back to 2 if facts are missing
workers  = {{ (ansible_facts.processor_vcpus | default(1) | int) * 2 }}
listen   = {{ bind_address | default('0.0.0.0') }}:{{ app_port | default(8080) }}
env      = {{ app_env | default('dev') | upper }}

[features]
{% for name, on in features.items() %}
{{ name }} = {{ on | ternary('enabled', 'disabled') }}
{% endfor %}

[allowed_admins]
{% if admins | default([]) | length > 0 %}
admins = {{ admins | join(', ') }}
{% else %}
# no admins configured
{% endif %}

[backends]
{% for b in backends | default([]) -%}
server {{ b }}  # {{ loop.index }}/{{ loop.length }}
{% endfor -%}

jinja-lab/render.yml:

- name: Render an application config from Jinja2
  hosts: localhost
  connection: local
  gather_facts: true            # we need ansible_facts.processor_vcpus
  vars:
    app_env: prod
    app_port: 9090
    admins: ["alice", "bob"]
    features:
      tls: true
      cache: false
      metrics: true
    backends:
      - "10.0.0.11:8080"
      - "10.0.0.12:8080"
  tasks:
    - name: Render the config to /tmp (validate it is non-empty)
      ansible.builtin.template:
        src: app.conf.j2
        dest: /tmp/app.conf
        mode: "0644"
        backup: true
        trim_blocks: true
        lstrip_blocks: true
        validate: "test -s %s"      # %s = the rendered temp file; fail if empty
      register: render_result

    - name: Show whether it changed and where the backup went
      ansible.builtin.debug:
        msg: "changed={{ render_result.changed }} backup={{ render_result.backup_file | default('none') }}"

    - name: Print the rendered file back (proves the result)
      ansible.builtin.debug:
        msg: "{{ lookup('ansible.builtin.file', '/tmp/app.conf') }}"

2. Syntax-check, then run. From inside jinja-lab/:

ansible-playbook --syntax-check render.yml
ansible-playbook render.yml

Expected: the play gathers facts, the template task reports changed on the first run, and the final debug prints the rendered /tmp/app.conf. It should look like a clean INI file — env = PROD, listen = 0.0.0.0:9090, a [features] block with tls = enabled, cache = disabled, metrics = enabled, an admins = alice, bob line, and two server … backend lines numbered 1/2 and 2/2 with no stray blank lines (that’s trim_blocks/lstrip_blocks and the -%} doing their job).

3. Prove idempotency. Run ansible-playbook render.yml again. Because ansible_managed is static (no {date}) and the variables are unchanged, the template task now reports ok (not changed) — the rendered output is byte-identical to what’s on disk.

4. See a diff. Change app_port: 9090 to app_port: 8443 and run with --diff:

ansible-playbook render.yml --diff

Ansible prints a unified diff showing only the listen line changing, the task reports changed, and backup_file now points at a timestamped backup of the previous version (because backup: true).

5. Experiment with whitespace. Remove the - from {% endfor -%} (make it {% endfor %}), re-run, and inspect /tmp/app.conf — you’ll see how the trailing newline behaviour shifts. Put it back. This is the fastest way to build intuition for the - modifier.

Validation. You have a rendered config driven by facts (processor_vcpus), filters (default, int, upper, join, ternary), a for over a dict and a list, an if/else, the ansible_managed banner, validate, backup, and clean whitespace — the whole lesson in one file.

Cleanup.

rm -f /tmp/app.conf /tmp/app.conf.*~ 2>/dev/null
# then delete the jinja-lab/ directory if you wish

Cost note. Everything ran against localhost with the local connection — no cloud resources, no remote hosts, ₹0.

Common mistakes & troubleshooting

Symptom Cause Fix
AnsibleUndefinedVariable: 'foo' is undefined when rendering A variable referenced in the template isn’t defined for that host Guard with {{ foo | default(...) }}, or {% if foo is defined %}; for required values use | mandatory to fail clearly.
Generated file is full of blank lines Default Jinja2 whitespace around {% %} tags Enable trim_blocks: true + lstrip_blocks: true on the task and add - modifiers ({%- … -%}) where needed.
Octal mode produces wrong permissions mode: 0644 read by YAML as the decimal 644 Quote it: mode: "0644".
template reports changed every run A {date}/timestamp in ansible_managed (or in the template) makes output differ each time Drop {date} from ansible_managed; rely on --diff/backups for the audit trail.
default filter “doesn’t work” on an empty string default only fires on undefined, not empty/false Use the two-arg form: | default('x', true).
A loop/map/selectattr prints a <generator object …> or wrong count Lazy generator not materialised Append | list before assigning or counting.
set inside a for loop “forgets” its value after the loop Loop block scoping — assignments don’t escape the iteration Use a namespace() object, or (better) compute it in the play with set_fact and reference the fact.
lookup('file', ...) reads the wrong file / fails on the target Lookups run on the control node, not the managed node To read a file on the target use slurp/fetch; lookups are control-node only.
validate command “passes” a broken file %s placeholder missing, so the validator didn’t get the temp path Ensure the command contains exactly one %s (validate: "nginx -t -c %s").
Templating a file that itself contains {{ }} mangles it Jinja2 tries to evaluate the file’s literal braces Override variable_start_string/variable_end_string on the task, or wrap that section in {% raw %}…{% endraw %}.
loop: over a multi-result lookup only gets one comma-joined item lookup() returns a joined string Use query() (q()) or wantlist=true.

Best practices

Security notes

Templates are a common place for secrets to leak, so treat them carefully. First, render secret-bearing files with restrictive modes (mode: "0600", correct owner) so the on-disk result isn’t world-readable. Second, --diff prints file contents — including any secret a template renders — to the terminal and to CI logs; set no_log: true on template tasks that render credentials (and on the lookups that fetch them), and remember that ANSIBLE_DEBUG=1 or -vvvv can defeat no_log, so never run secret-rendering plays at maximum verbosity. Third, lookups run on the control node: lookup('file', …) and lookup('env', …) read your machine, so be sure the control node’s filesystem and environment are themselves trusted, and never echo a lookup('password', …) or a Vault lookup into a debug. Fourth, prefer pulling real secrets from Ansible Vault (!vault vars, covered in the Vault lesson) or an external secrets manager and templating them in at run time, rather than committing rendered secrets or plaintext defaults to git. Finally, the password lookup writes the generated password to a file on the control node — keep that path out of version control and protected, or it becomes a plaintext secret store by accident.

Interview & exam questions

1. When would you use template instead of copy? Use template when the file’s contents depend on variables or facts and must be rendered through Jinja2 ({{ }}, {% %}); use copy for a static file with nothing to substitute. template shares copy’s attribute controls (mode, owner, backup, validate) plus rendering.

2. Where does the template module actually render the file — control node or managed node? On the control node. Ansible reads the .j2, renders it locally using the target host’s already-gathered variables/facts, then copies the finished file to the managed node. The template never executes on the target.

3. What are the three Jinja2 delimiters and what does each do? {{ }} is an expression (evaluates and prints a value), {% %} is a statement (control logic — if/for/set — that prints nothing itself), and {# #} is a comment (removed entirely from output).

4. Explain trim_blocks, lstrip_blocks, and the - modifier. trim_blocks (on by default in Ansible) removes the newline right after a %}. lstrip_blocks strips leading whitespace up to a {%, letting you indent logic without it leaking. The - modifier ({%- … -%}) manually strips whitespace before/after a specific tag. Together they keep generated files free of phantom blank lines.

5. What’s the difference between the default filter with and without its second argument? var | default('x') substitutes only when var is undefined. var | default('x', true) also substitutes when var is empty or false. The two-arg form is what you want for “use this unless there’s a real value”.

6. default vs mandatory — when each? default provides a fallback for a missing variable; mandatory does the opposite — it raises an error if the variable is undefined, so a required input fails loudly instead of silently defaulting.

7. What does the combine filter do, and why recursive=true? combine merges two dictionaries, with the right-hand dict winning on key conflicts. By default it merges only the top level; recursive=true deep-merges nested dicts — the idiomatic way to layer an environment override on top of a defaults dict.

8. Difference between a filter and a test? A filter (applied with |) transforms a value and returns a new one (x | upper). A test (applied with is) asks a yes/no question and returns a boolean (x is defined, n is even). Tests go in {% if %} and when:; filters shape data.

9. is match vs is search? is match anchors the regex at the start of the string; is search matches anywhere in it. Picking the wrong one is a classic bug — host is match('web') is false for myweb01, but is search('web') is true.

10. What is a lookup, where does it run, and how does lookup differ from query? A lookup pulls external data into a play (file contents, env vars, a generated password), running on the control node at templating time. lookup() returns a single comma-joined string for multi-valued results; query() (q()) returns a real list — use query for loop: sources (or pass wantlist=true).

11. Why does a variable set inside a for loop not persist after the loop, and how do you work around it? Each loop iteration has its own scope, so the assignment is discarded at the end of the iteration. Work around it with a namespace() object (attributes survive) or, better in Ansible, compute the value in the play with set_fact and reference the resulting fact.

12. What is ansible_managed and what’s the catch with putting a timestamp in it? It’s a variable (default Ansible managed) you stamp at the top of generated files to warn against hand-editing; you can enrich it via ansible.cfg with {file}/{host}/{date}. The catch: a {date} token makes the rendered file change every run, so the template task reports changed every time — defeating idempotent reporting. Keep the banner static if that matters.

13. How do you safely render a file that itself contains literal {{ }} (e.g. another Jinja template)? Override variable_start_string/variable_end_string on the template task (e.g. to [%/%]) so your placeholders don’t collide, or wrap the literal section in {% raw %}…{% endraw %}.

14. How would you build an Nginx upstream block from the hosts in the web group? Loop the group inside the template and pull each host’s IP from hostvars: {% for h in groups['web'] %}server {{ hostvars[h].ansible_default_ipv4.address }}:8080;{% endfor %}, using loop.first/loop.last for any special-casing.

Quick check

  1. Which delimiter prints a value, which runs logic, and which is a comment?
  2. True or false: myvar | default("x") substitutes "x" when myvar is an empty string "".
  3. You want a real list back from a multi-result lookup to drive a loop:. Which function do you call?
  4. What’s the difference between is match('web') and is search('web') against the string "myweb01"?
  5. Why might a template task report changed on every single run even when no variables changed?

Answers

  1. {{ }} prints an expression; {% %} runs a statement (control logic, no output); {# #} is a comment (removed from output).
  2. False. default fires only on undefined. For an empty string you need the boolean form default("x", true).
  3. query() (alias q()), or lookup(..., wantlist=true). Plain lookup() returns a comma-joined string.
  4. is match('web') is false (it anchors at the start; the string begins with my); is search('web') is true (it matches anywhere).
  5. Because the rendered output differs each run — almost always a timestamp (e.g. {date} in ansible_managed, or ansible_date_time in the template). Remove the timestamp to restore idempotent reporting.

Exercise

Extend the lab into something closer to a real service config.

  1. Add a variable tls_cert_path and render an Nginx-style ssl_certificate line only when enable_tls | default(false) is true, using {% if %} — and add an {% else %} that writes a # TLS disabled comment.
  2. Add a backends list of dictionaries (each with host, port, and weight) and render an upstream block by looping it, emitting server {{ b.host }}:{{ b.port }} weight={{ b.weight }};. Use loop.first/loop.last to omit a trailing separator, and sort the list by weight with | sort(attribute='weight').
  3. Use combine to merge a defaults dict ({ timeout: 30, keepalive: 65 }) with an overrides dict, and render the merged result with to_nice_yaml into a [tuning] section.
  4. Add validate appropriate to your file (if you have nginx installed, nginx -t -c %s; otherwise keep test -s %s), turn on backup, and run with --check --diff first, then for real.
  5. Finally, set ansible_managed in ansible.cfg to include {file} and {host} (but not {date}), re-run twice, and confirm the second run reports ok — then add {date}, re-run twice, and watch it report changed both times. Write two sentences explaining why.

This mirrors exactly the kind of “template a service config from variables and facts, with validation” task the RHCE exam sets.

Certification mapping

This lesson maps to the Red Hat Certified Engineer (RHCE) EX294 exam, where templating is one of the most reliably-tested skills. It covers the exam objectives Create and use templates to create customised configuration files (the template module, .j2 files, rendering facts and variables into config) and Use Jinja2 to reference variables, facts, and the magic variables (expressions, filters, conditionals, and loops inside templates), and it reinforces Work with variables and facts and Use conditionals to control play execution from the templating side. On the exam you will almost certainly be asked to deploy a config file built from a template that adapts to each host’s facts (CPU count, hostname, OS family) and group membership — exactly the pattern in the lab. The broader Ansible Automation Platform / EX374 advanced exam pushes the same skills further (custom filters, more lookups). The earlier Variables & Facts and Conditionals, Loops, Handlers & Tags lessons supply the inputs your templates consume.

Glossary

Next steps

You can now turn variables and facts into real, validated configuration files: drive the template module option by option, write conditionals and loops, reach for the right filter or test, pull data in with lookups, and keep your output clean with whitespace control and an ansible_managed banner. The natural next move is to make your playbooks resilient when those tasks go wrong. Continue with Ansible Error Handling, In Depth: Blocks, rescue/always, failed_when, changed_when & ignore_errors, which wraps groups of tasks (including the template tasks you just wrote) in block/rescue/always, and gives you precise control over what counts as failed and changed — the difference between a playbook that limps and one that recovers. To revisit the inputs your templates consume, see Ansible Variables & Facts, In Depth, and for the modules that lay files down alongside template, Ansible Core Modules for Real Work.

ansiblejinja2templatesfilterslookupsrhce
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments