Module 04 — Jinja2 Templating

Updated 20 August 2026

Module 04 · Jinja2 Templating

Generating configuration files that are correct, readable and idempotent. This is where Ansible stops copying static files and starts producing config shaped by the host it is running against.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–03. You have used filters throughout — default, bool, dict2items, version. This module covers the engine underneath them.


Part A · How templating works

A1 · The three delimiters

The analogy. Think of a wedding invitation. The card is printed once and reads "Dear __, we would be delighted…". You write a different name in the blank for each guest, and every other word on the card stays exactly the same. One card design, three hundred personalised results.

A Jinja2 template is that card, and {{ guest_name }} is the blank. The three kinds of marking on it do three different jobs: {{ }} fills in a blank, {% %} is an instruction to whoever is printing — "repeat this line once per guest" — and {# #} is a pencil note to yourself that never appears on the finished card.

Jinja2 has exactly three delimiter types, and confusing them is the source of most template errors.

SyntaxNameDoes what
{{ ... }}ExpressionEvaluates something and prints the result into the output
{% ... %}StatementControl flow — for, if, set, macro. Prints nothing itself
{# ... #}CommentRemoved entirely — never appears in the rendered file
javascript
{# This comment will not appear in the output file #}
{% set worker_count = ansible_facts['processor_vcpus'] * 2 %}

worker_processes {{ worker_count }};

{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:8080;
{% endfor %}
The distinction that trips people up: {% if x %} decides whether something is emitted; {{ x }} emits a value. Writing {{ if x }} or {% my_var %} are both errors, and the error messages are not always obvious about which mistake you made.

A2 · Where templating happens — and where it does not

The analogy. Think about where you actually write the guest's name on that invitation. You fill it in at your own kitchen table and then post the finished card. You do not post a blank card and a pen and ask the guest to fill in their own name — that would be absurd, and they would not know what to write.

Templates are rendered on the control node, and only the finished text is sent. The guest receives a completed card and has no idea a template was ever involved — which is why the target server needs no templating software installed, and why lookup('file', …) inside a template reads your filing cabinet rather than the server's. People lose hours to that last point.

Diagram source
flowchart TD
    A["nginx.conf.j2<br>lives on the CONTROL NODE"] --> B["Jinja2 renders it<br>ON THE CONTROL NODE<br>using this host's variables and facts"]
    B --> C["Result is a plain text file<br>no Jinja2 left in it"]
    C --> D["Copied to the managed node<br>via the normal module transport"]
    D --> E{"validate: supplied?"}
    E -->|"Yes"| F["Run the validator<br>against the TEMP file"]
    F -->|"fails"| G["Task fails<br>live file untouched"]
    F -->|"passes"| H["Move into place"]
    E -->|"No"| H
    H --> I{"Content differs<br>from what was there?"}
    I -->|"Yes"| J["changed: true<br>handlers notified"]
    I -->|"No"| K["ok: unchanged<br>handlers NOT notified"]
    style B fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style G fill:#FEE2E2,stroke:#DC2626
    style K fill:#D1FAE5,stroke:#059669
Templates render on the control node, never on the target. The managed node receives finished text and has no idea Jinja2 was involved. This is why the target needs no Jinja2, no Python templating library, nothing.

It is also why lookup('file', ...) inside a template reads a control node file — the same rule from Module 02 Part D4.

Templating is not limited to the template module

Ansible templates almost every string in a playbook before using it:

yaml
- name: "Deploy {{ app_name }}"          # task names are templated
  ansible.builtin.copy:
    dest: "/opt/{{ app_name }}/config"    # arguments are templated
    content: "port={{ app_port }}"
  when: app_port | int > 1024             # conditions are Jinja2 expressions
  loop: "{{ app_list }}"                  # loop sources are templated
  vars:
    computed: "{{ base_dir }}/{{ app_name }}"   # variables can reference variables
The one place templating does NOT happen: inside a file copied with copy. copy transfers bytes verbatim. If your file contains {{ something }}, it arrives with the braces intact.

That is occasionally exactly what you want — deploying a file that itself contains Jinja2 syntax, such as a Grafana dashboard or a Prometheus rule using its own templating.

A3 · template vs copy vs lineinfile vs blockinfile

The analogy. Think about how you handle a document. If you wrote it and it changes for each recipient, you use the card with blanks. If you wrote it and it is identical for everyone, you just photocopy it. And if it is somebody else's printed form and you only need to change one line, you cross that line out and write above it.

The card with blanks is template, the photocopy is copy, and the crossing-out is lineinfile (with blockinfile for crossing out a whole paragraph at once). The warning is the one your eyes give you: fifteen crossings-out on a single page means you should have typed a fresh document. That is precisely the moment a pile of lineinfile tasks should have become one template.

ModuleUse whenDo not use when
templateYou own the whole file and it varies by hostThe content is identical everywhere — copy is cheaper and clearer
copyYou own the whole file and it is staticAnything needs substituting
lineinfileYou need one line in a file owned by the OS or a packageYou are managing more than two or three lines — it gets unreadable fast
blockinfileYou need a managed section inside someone else's fileYou own the whole file — use template
The rule that answers the interview question: if you own the entire file, template or copy it — declaring the whole content is idempotent and reviewable. If you are editing a file that a package owns and will overwrite on upgrade, use lineinfile or blockinfile.

People who reach for lineinfile fifteen times on one file should have written a template. It is slower, harder to review, and each lineinfile is an independent regex that can drift.

🧪 Exercise A3.1 — Prove that templates render on the control node
bash
mkdir -p templates
cat > templates/proof.j2 <<'EOF'
rendered_on_control_node = {{ lookup('pipe', 'hostname') }}
rendered_for_target      = {{ inventory_hostname }}
target_reported_hostname = {{ ansible_facts['hostname'] }}
control_node_user        = {{ lookup('env', 'USER') }}
EOF
yaml
---
- name: Where does templating happen?
  hosts: web01
  gather_facts: true
  tasks:
    - ansible.builtin.template:
        src: proof.j2
        dest: /tmp/proof.txt
        mode: "0644"

    - ansible.builtin.command: cat /tmp/proof.txt
      register: out
      changed_when: false

    - ansible.builtin.debug:
        var: out.stdout_lines
Expected result — click to reveal
plain text
ok: [web01] => {
    "out.stdout_lines": [
        "rendered_on_control_node = zaeem-laptop",
        "rendered_for_target      = web01",
        "target_reported_hostname = web01",
        "control_node_user        = zaeem"
    ]
}

Line 1 is the proof. lookup('pipe', 'hostname') ran hostname and got your laptop, not web01 — because lookups and template rendering both happen on the control node.

Line 3 shows the target's hostname, but only because it arrived as a gathered fact — collected earlier by the setup module and then substituted locally during rendering. The template itself never executed anything on web01.

Why this matters practically: if you need a value that only exists on the target and is not a fact, you cannot get it with a lookup. You must run a task with register first, then reference the registered value in the template. Reaching for lookup('file', '/etc/something') and getting the control node's copy is a genuinely common bug.

🎯 Interview questions — Templating basics

Q. What is the difference between {{ }}, {% %} and {# #}?

{{ }} is an expression — it evaluates and prints the result into the output.

{% %} is a statement — control flow such as for, if, set, macro. It prints nothing itself.

{# #} is a comment — stripped entirely, never present in the rendered file. Useful for notes you do not want shipped to production.

Q. Where does template rendering happen — control node or target?

The control node, always. Jinja2 renders the file locally using that host's variables and facts, then the finished plain text is transferred to the target.

That is why the managed node needs no templating library, and why lookup() inside a template reads control-node files rather than target files. To use a value that exists only on the target, run a task and register it first.

Q. When would you use template over copy, and lineinfile over both?

template when you own the entire file and its content varies by host. copy when you own it and it is identical everywhere — cheaper and clearer.

lineinfile or blockinfile when the file is owned by the OS or a package and you only need to manage a line or a section within it.

The warning worth adding: fifteen lineinfile tasks against one file should have been a template. Each is an independent regex that can drift, and the result is far harder to review than a single declared file.

Q. Is anything besides the template module templated?

Almost everything. Task names, module arguments, when: conditions, loop: sources, variable definitions that reference other variables — all rendered through Jinja2 before use.

The notable exception is a file transferred with copy, which is sent byte-for-byte. That is deliberate, and it is how you deploy a file that legitimately contains Jinja2 syntax of its own — a Grafana dashboard or a Prometheus rule, for example.


Part B · Filters

Official docs: Using filters to manipulate data · Manipulating data — complex patterns · offline: ansible-doc -t filter -l
The analogy. Think of a kitchen production line. A potato goes in one end, gets washed, then peeled, then chopped, then fried — each station doing one small job and handing the result straight to the next one.

Filters are those stations, and the | is the conveyor belt. {{ name | trim | lower | replace(' ', '-') }} washes, peels and chops, strictly left to right. And exactly as in a real kitchen, the order is not a detail: frying before peeling gives you something quite different, and most "my filter is not working" problems are really a station in the wrong place.

A filter transforms a value. Syntax is value | filter and they chain left to right.

javascript
{{ "  Hello World  " | trim | lower | replace(' ', '-') }}     ->  hello-world

B1 · String filters

yaml
"{{ name | upper }}"                         # ZAEEM
"{{ name | lower }}"                         # zaeem
"{{ name | capitalize }}"                    # Zaeem
"{{ text | trim }}"                          # strip leading/trailing whitespace
"{{ text | replace('old', 'new') }}"
"{{ path | basename }}"                      # /opt/app/x.conf -> x.conf
"{{ path | dirname }}"                       # /opt/app/x.conf -> /opt/app
"{{ file | splitext | first }}"              # x.conf -> x
"{{ csv | split(',') }}"                     # string -> list
"{{ list | join(', ') }}"                    # list -> string
"{{ text | regex_replace('^v', '') }}"       # v2.4.1 -> 2.4.1
"{{ text | regex_search('[0-9]+\\.[0-9]+') }}"
"{{ log | regex_findall('ERROR: (.*)') }}"   # every match, as a list
"{{ cmd | quote }}"                          # shell-escape - IMPORTANT for safety
"{{ text | comment }}"                       # wrap in # comment markers
| quote is a security control, not a convenience. Any user-controlled value interpolated into a shell or command task must be quoted, or a value containing ; rm -rf / becomes a second command.
yaml
- ansible.builtin.shell: "grep {{ user_pattern | quote }} /var/log/app.log"

Better still, avoid shell entirely. But when you cannot, | quote is the answer, and knowing it exists is exactly the kind of detail that reads as security-aware in an interview.

B2 · List and dictionary filters

yaml
"{{ list | length }}"                        # count
"{{ list | first }}"  /  "{{ list | last }}"
"{{ list | unique }}"                        # de-duplicate
"{{ list | sort }}"  /  "{{ list | sort(reverse=true) }}"
"{{ list | flatten }}"                       # nested list -> flat
"{{ list_a | union(list_b) }}"               # A or B
"{{ list_a | intersect(list_b) }}"           # A and B
"{{ list_a | difference(list_b) }}"          # in A, not in B
"{{ list | random }}"  /  "{{ list | shuffle }}"
"{{ dict | dict2items }}"                    # {a: 1} -> [{key: a, value: 1}]
"{{ items | items2dict }}"                   # the reverse
"{{ dict_a | combine(dict_b, recursive=True) }}"    # merge - Module 02

map, select, reject — the ones that separate people

yaml
# Extract one attribute from a list of dictionaries
"{{ users | map(attribute='name') | list }}"
#   [{name: a, uid: 1}, {name: b, uid: 2}]  ->  ['a', 'b']

# Filter a list of dictionaries by an attribute
"{{ users | selectattr('uid', '>=', 2000) | list }}"
"{{ users | selectattr('shell', 'defined') | list }}"
"{{ users | rejectattr('name', 'equalto', 'root') | list }}"

# Filter a plain list
"{{ ports | select('>', 1024) | list }}"
"{{ names | select('match', '^web') | list }}"

# Chain them - extract, filter, sort, join
"{{ users | selectattr('active') | map(attribute='name') | sort | join(', ') }}"
map and select return generators, not lists. In Jinja2 they are lazy, so you almost always need a trailing | list before the result is usable. Forgetting it produces output like <generator object at 0x7f...> in your config file — the same class of silent wrongness as the dot-notation trap in Module 02.

B3 · Type, math and default filters

yaml
"{{ value | int }}"  /  "{{ value | float }}"  /  "{{ value | bool }}"
"{{ value | string }}"
"{{ value | type_debug }}"                   # what type IS this? - Module 02
"{{ myvar | default('fallback') }}"
"{{ myvar | default('fallback', true) }}"    # also replaces empty/falsy
"{{ myvar | default(omit) }}"                # remove the argument entirely
"{{ myvar | mandatory }}"                    # fail loudly if undefined
"{{ condition | ternary('yes', 'no') }}"     # inline if/else
"{{ nums | max }}"  /  "{{ nums | min }}"  /  "{{ nums | sum }}"
"{{ number | round(2) }}"
"{{ bytes | human_readable }}"               # 1048576 -> 1.00 MB
"{{ '1.5GB' | human_to_bytes }}"             # -> 1610612736
ternary is the inline conditional and keeps templates readable:
javascript
worker_processes {{ (ansible_facts['processor_vcpus'] > 4) | ternary('auto', 2) }};

It also takes a third argument for the undefined case: ternary('yes', 'no', 'unknown').

B4 · Data format filters

yaml
"{{ data | to_json }}"                       # compact JSON
"{{ data | to_nice_json(indent=2) }}"        # pretty JSON
"{{ data | to_yaml }}"
"{{ data | to_nice_yaml(indent=2) }}"        # readable YAML - great in templates
"{{ json_string | from_json }}"              # parse JSON into a real object
"{{ yaml_string | from_yaml }}"
"{{ text | b64encode }}"  /  "{{ text | b64decode }}"
"{{ 'string' | hash('sha256') }}"
"{{ 'string' | password_hash('sha512') }}"   # for the user module's password field
password_hash without a fixed salt is not idempotent. Each run generates a new random salt, producing a different hash, so the user module reports changed and resets the password every single run.
yaml
# WRONG - changed on every run, forever
password: "{{ raw_pw | password_hash('sha512') }}"

# RIGHT - deterministic salt derived from something stable
password: "{{ raw_pw | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}"

This is a genuinely common production bug and a favourite interview follow-up on idempotency, because it looks correct and behaves badly.

B5 · Network and time filters

yaml
"{{ '192.168.1.5/24' | ansible.utils.ipaddr('network') }}"      # 192.168.1.0
"{{ '192.168.1.0/24' | ansible.utils.ipaddr('netmask') }}"      # 255.255.255.0
"{{ addr | ansible.utils.ipaddr('address') }}"
"{{ 'https://a.com:8080/path' | urlsplit('hostname') }}"        # a.com
"{{ '%Y-%m-%d' | strftime }}"                                   # today's date
"{{ ansible_date_time.epoch | int | strftime('%Y-%m-%d %H:%M') }}"
ipaddr lives in the ansible.utils collection and needs the Python netaddr library on the control node — a good example of why FQCN matters, from Module 01 Part A4. ansible-doc -t filter -l | grep ipaddr confirms whether you have it.
🧪 Exercise B5.1 — Build a real value with a filter chain
yaml
---
- name: Filter chains
  hosts: localhost
  gather_facts: false
  vars:
    app_users:
      - { name: "root",    uid: 0,    active: true  }
      - { name: "deploy",  uid: 2001, active: true  }
      - { name: "monitor", uid: 2002, active: true  }
      - { name: "olduser", uid: 2003, active: false }
  tasks:
    - name: Active non-root users, sorted, comma-separated
      ansible.builtin.debug:
        msg: >-
          {{ app_users
             | selectattr('active')
             | rejectattr('name', 'equalto', 'root')
             | map(attribute='name')
             | sort
             | join(', ') }}

    - name: What happens WITHOUT the final list
      ansible.builtin.debug:
        msg: "{{ app_users | map(attribute='name') }}"

    - name: With the final list
      ansible.builtin.debug:
        msg: "{{ app_users | map(attribute='name') | list }}"
Expected result — click to reveal
plain text
TASK [Active non-root users, sorted, comma-separated] **************
ok: [localhost] => {
    "msg": "deploy, monitor"
}

TASK [What happens WITHOUT the final list] *************************
ok: [localhost] => {
    "msg": "<generator object do_map at 0x7f2a1c4d5e40>"
}

TASK [With the final list] *****************************************
ok: [localhost] => {
    "msg": ["root", "deploy", "monitor", "olduser"]
}

The middle task is the lesson. map returned a lazy generator, and rendering it produced a Python repr string. No error, no warning — and if that had gone into a config file you would have deployed <generator object do_map at 0x7f2a1c4d5e40> to production.

The rule: map, select, reject, selectattr and rejectattr all need a trailing | list unless another filter that consumes them follows (join, sort, length all force evaluation, which is why the first task worked without one).

Also note the >- folded scalar used to split the chain across lines. A six-filter chain on one line is unreadable; this keeps it reviewable in a diff without changing the value.

🎯 Interview questions — Filters

Q. How do you extract one field from a list of dictionaries?

{{ users | map(attribute='name') | list }}.

To filter first, selectattr and rejectattr: {{ users | selectattr('active') | map(attribute='name') | list }}.

The detail worth volunteering: map and select return generators, so a trailing | list is needed unless a consuming filter such as join or sort follows. Without it you render a <generator object ...> string into your file with no error.

Q. Why is password_hash a classic idempotency bug?

Without a fixed salt it generates a new random salt on every run, producing a different hash each time. The user module then sees a different value, reports changed, and resets the password on every single run — which also fires any handler attached to it.

The fix is a deterministic salt, typically derived from something stable like inventory_hostname via random(seed=...). Better still, store an already-hashed value in Vault.

Q. How do you safely interpolate a variable into a shell command?

| quote — it shell-escapes the value, so a string containing ; or backticks cannot break out and run a second command.

The stronger answer is to not use shell at all where a real module exists. When you must, | quote on every user-controlled value is mandatory, and command is preferable to shell because it does not involve a shell at all.

Q. What is ternary for?

An inline conditional inside an expression: {{ (vcpus > 4) | ternary('auto', 2) }}. It keeps a template readable when a full {% if %} block would be heavy for a single value.

It takes an optional third argument for the undefined case.

Q. How do you merge two dictionaries in a template?

{{ base | combine(overrides, recursive=True) }}.

recursive=True matters whenever there are nested dictionaries — without it the nested level is replaced wholesale rather than merged, which silently drops keys. Covered in Module 02 Part D2.


Part C · Control structures in templates

C1 · Loops

The analogy. Think of writing out the guest list by hand, one line per guest in the same format each time. You need to know when you have reached the last name, because that one does not get a comma after it — and you number them down the margin as you go. If nobody replied at all, you write "no guests" rather than handing over a blank page.

loop.last is knowing you are on the final name, loop.index is the number in the margin, and {% else %} inside a for loop is writing "no guests". That trailing comma sounds trivial until it is the difference between valid and invalid JSON in a generated config file.

javascript
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:8080;
{% endfor %}

The loop object — available inside any for

VariableValue
loop.index1-based counter
loop.index00-based counter
loop.first / loop.lastbooleans — very useful for separators
loop.lengthtotal number of items
loop.revindexcounts down to 1
javascript
{# Comma-separated list with no trailing comma #}
servers = [{% for h in groups['web'] %}"{{ h }}"{% if not loop.last %}, {% endif %}{% endfor %}]

{# Or simply, which is clearer: #}
servers = {{ groups['web'] | map('quote') | join(', ') }}
{% for %} also takes an {% else %}, which runs when the sequence is empty:
javascript
{% for h in groups['web'] %}
server {{ h }};
{% else %}
# no web servers defined
{% endfor %}

That is a genuinely useful pattern — an empty loop silently producing an empty config block is how you end up with an nginx upstream that has no members and refuses to start.

C2 · Conditionals and set

javascript
{% if ansible_facts['os_family'] == 'Debian' %}
include /etc/nginx/sites-enabled/*;
{% elif ansible_facts['os_family'] == 'RedHat' %}
include /etc/nginx/conf.d/*.conf;
{% else %}
# unsupported platform: {{ ansible_facts['os_family'] }}
{% endif %}

{% set worker_count = (ansible_facts['processor_vcpus'] * 2) | int %}
{% set is_prod = 'prod' in group_names %}
worker_processes {{ worker_count }};
{% if is_prod %}error_log /var/log/nginx/error.log warn;{% endif %}
{% set %} inside a {% for %} does not escape the loop. Jinja2 scoping means a variable set inside a loop body is discarded at the end of each iteration, so the classic "accumulate a total in a loop" pattern silently produces the wrong answer.

Use a namespace object instead:

javascript
{% set ns = namespace(total=0) %}
{% for item in items %}{% set ns.total = ns.total + item.size %}{% endfor %}
Total: {{ ns.total }}

Or, far better in Ansible, do it with a filter: {{ items | map(attribute='size') | sum }}.

C3 · Whitespace control — the thing that makes templates look professional

The analogy. Think of the pencil guidelines on a hand-drawn poster. You rule light lines to keep your writing straight, they help enormously while you work — and you rub them out before anyone sees the finished thing.

The {% if %} and {% for %} lines in a template are those guidelines: they exist to organise you, not to appear in the output. Without whitespace control they leave their marks behind as stray blank lines and doubled-up indentation in your finished config file. trim_blocks and lstrip_blocks are the eraser — and in a YAML or Python config, where indentation carries meaning, those leftover pencil marks are not cosmetic.

Jinja2 statements sit on their own lines, and by default those lines leave blank space behind.

javascript
{% for h in hosts %}
server {{ h }};
{% endfor %}
ControlAnsible defaultEffect
trim_blockstrueRemoves the first newline after a block tag
lstrip_blocksfalseStrips whitespace from line start up to a block tag
{%- ... %}Strip whitespace before this tag
{% ... -%}Strip whitespace after this tag
Ansible sets trim_blocks: true by default, which plain Jinja2 does not. So Ansible templates are already tidier than raw Jinja2 examples you find online — and if you copy a template into a non-Ansible Jinja2 project, the whitespace changes. That difference catches people out and is a nice detail to know.

The one you usually still want is lstrip_blocks: true, which lets you indent your {% if %} and {% for %} tags to match the structure of the file without those indents appearing in the output.

🧪 Exercise C3.1 — Fix an ugly generated file
javascript
{# templates/upstream.j2 #}
upstream backend {
    {% for host in groups['web'] %}
    server {{ host }}:8080;
    {% endfor %}
}
yaml
- ansible.builtin.template:
    src: upstream.j2
    dest: /tmp/upstream.conf
    mode: "0644"
# then add lstrip_blocks and compare:
# lstrip_blocks: true
Expected result — click to reveal

Default — trim_blocks: true, lstrip_blocks: false:

plain text
upstream backend {
        server web01:8080;
        server web02:8080;
        server web03:8080;
}

Look closely: eight spaces of indent instead of four. The four spaces you used to indent {% for %} were emitted into the output and added to the four in front of server.

With lstrip_blocks: true:

plain text
upstream backend {
    server web01:8080;
    server web02:8080;
    server web03:8080;
}

And if trim_blocks were false — as it is in plain Jinja2 — you would additionally get a blank line after every {% endfor %} and {% if %}, producing a config file with gaps scattered through it.

Why this is worth caring about: a generated config with erratic indentation and stray blank lines is harder for humans to review during an incident, and in whitespace-sensitive formats — YAML, Python, Makefiles — it is not cosmetic at all, it is broken output. Set lstrip_blocks: true on templates where you indent your tags.

C4 · Macros and template reuse

javascript
{# macros.j2 #}
{% macro server_block(name, port, ssl=false) -%}
server {
    listen {{ port }}{% if ssl %} ssl{% endif %};
    server_name {{ name }};
}
{%- endmacro %}
javascript
{# site.conf.j2 #}
{% import 'macros.j2' as m %}
{{ m.server_block('example.com', 80) }}
{{ m.server_block('secure.example.com', 443, ssl=true) }}
DirectiveBehaviour
{% include 'f.j2' %}Renders another template inline, with access to the current context
{% import 'f.j2' as m %}Imports macros. Does not get the current context by default
{% import ... with context %}Imports macros and passes the current variables
Imported macros cannot see your variables unless you say with context. A macro that references ansible_facts directly will fail with an undefined-variable error under a plain import. Either pass what it needs as arguments — which is cleaner and more testable — or import with context.

🎯 Interview questions — Template control structures

Q. What is trim_blocks and what is Ansible's default?

It removes the first newline after a block tag, so {% for %} and {% endfor %} on their own lines do not each leave a blank line behind.

Ansible sets it to true by default, which differs from plain Jinja2 where it is false. lstrip_blocks defaults to false in both, and is the one you usually want to enable so you can indent your tags to match the file structure without those indents appearing in the output.

Q. Why does accumulating a total inside a {% for %} loop not work?

Jinja2 scoping — a variable {% set %} inside a loop body is discarded at the end of each iteration, so the total resets. It produces a wrong answer silently rather than an error.

Fix with a namespace() object whose attributes persist across iterations. In Ansible the better fix is usually to avoid the loop entirely: {{ items | map(attribute='size') | sum }}.

Q. How do you avoid a trailing comma when generating a list?

{% if not loop.last %}, {% endif %} inside the loop, using the loop.last boolean.

The loop object also gives you index, index0, first, length and revindex.

Often cleaner in Ansible, though: skip the loop and use {{ mylist | join(', ') }}.

Q. Difference between include and import in a template?

{% include %} renders another template inline and inherits the current context, so it sees your variables.

{% import %} brings in macros and does not get the current context unless you add with context. A macro referencing ansible_facts under a plain import fails with an undefined-variable error.

The cleaner design is to pass what a macro needs as explicit arguments rather than relying on ambient context — it is easier to test and easier to read.


Part D · Templating in production

D1 · The template module's options that matter

The analogy. Think of printing five hundred copies of a poster. You proofread one before you press the button on the print run, because once five hundred copies exist with a typo in them the mistake is expensive, public and slow to undo.

validate: is that proofread. Ansible renders the config to a temporary file, runs the real checker against it — nginx -t, visudo -c, sshd -t — and only moves it into place if it passes. If it fails, the live file is never touched at all. That matters most for exactly the three files that lock you out of the building when you get them wrong: sshd, sudoers and nginx.

yaml
- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: "0644"
    validate: nginx -t -c %s        # test BEFORE putting it in place
    backup: true                    # keep a timestamped copy of the old file
    lstrip_blocks: true             # strip indent before block tags
    trim_blocks: true               # default, shown for clarity
    block_start_string: "[%"        # change delimiters when they clash
    variable_start_string: "[["
  notify: Reload nginx
OptionWhy you want it
validate:%s is replaced with the temp path. Fails the task and leaves the live file untouched if the config is invalid
backup: trueKeeps a timestamped copy on the target. Cheap insurance during a risky change
lstrip_blocks: trueLets you indent tags without the indent reaching the output
variable_start_stringEscape hatch when the target format uses {{ }} itself — Helm charts, Grafana dashboards, Prometheus rules
force: falseWrite only if the file does not already exist — for seeding a file a human will then edit
{{ ansible_managed }} belongs at the top of every generated file.
javascript
# {{ ansible_managed }}
# DO NOT EDIT - changes will be overwritten on the next Ansible run

It renders to something like Ansible managed, and the string is configurable in ansible.cfg. It is the difference between a colleague at 3am editing the file by hand and losing their work, and knowing immediately where the file comes from.

One caution worth knowing: if you configure it to include a timestamp or the source path, the file content changes on every run and the task reports changed forever — the same idempotency trap as password_hash. Keep it static.

D2 · Patterns you will actually write

An upstream block from group membership

javascript
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for host in groups['web'] %}
{%   if hostvars[host]['ansible_default_ipv4'] is defined %}
    server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_port }} max_fails=3;
{%   else %}
    # {{ host }}: facts not gathered - run a play against 'web' first
{%   endif %}
{% endfor %}
}

A config file from a dictionary

javascript
# {{ ansible_managed }}
{% for key, value in app_settings | dictsort %}
{{ key }} = {{ value }}
{% endfor %}
| dictsort is doing real work there. Dictionary ordering is not guaranteed to be stable across runs, so without sorting, the same data can render in a different order and the task reports changed for no reason — restarting the service each time.

Sorting anything you iterate is an idempotency measure, not a cosmetic one. Same reasoning applies to | sort on lists built from groups or from facts.

A systemd unit

javascript
# {{ ansible_managed }}
[Unit]
Description={{ app_name }}
After=network.target{% if app_needs_db %} postgresql.service{% endif %}

[Service]
User={{ app_user }}
Environment="PORT={{ app_port }}"
{% for k, v in (app_env | default({})) | dictsort %}
Environment="{{ k }}={{ v }}"
{% endfor %}
ExecStart={{ app_bin }} --config {{ app_config_path }}
Restart=always

[Install]
WantedBy=multi-user.target

D3 · The four gotchas

The analogy. Think of a letter that prints today's date at the top. Print it on Monday and again on Tuesday and the two sheets genuinely are different documents — even though nothing you actually care about has changed. Anyone comparing them mechanically will report a difference every single day, forever.

That is why a timestamp inside a generated file makes the task report changed on every run. The service then restarts nightly for no reason, and changed=0 — your best single signal that a system has settled — stops meaning anything at all. It is the six-eggs idea from Module 01 broken by one thoughtless line in a template.

1 · Undefined variables fail the whole template. One missing variable in a 200-line file and nothing is written. That is usually correct — a half-rendered config is worse — but it means every optional value needs | default(...).

You can change the behaviour with #jinja2: undefined: ChainableUndefined at the top of a template, but do not: silently rendering an empty string into a config file is far more dangerous than failing.

2 · Everything renders to a string by default. {{ 5 + 5 }} in a variable gives you the string "10", not the integer. This matters when the value feeds a when: or a numeric comparison.

Ansible has a native types mode — jinja2_native = True in ansible.cfg — which preserves real Python types. It is genuinely useful and it changes behaviour subtly across a whole project, so it is a decision to take deliberately rather than mid-debug.

3 · Templating is not recursive by default. A variable whose value contains {{ }} is templated once. Usually fine, occasionally surprising when you build variables out of other variables that themselves contain templates.
4 · A template containing the target format's own {{ }} will break. Helm charts, Grafana dashboards, Prometheus alerting rules and Vue templates all use {{ }}. Either change the delimiters with variable_start_string, or wrap the literal section in {% raw %} ... {% endraw %}, or use copy instead if nothing needs substituting.
🧪 Exercise D3.1 — Hit all four gotchas deliberately
javascript
{# templates/gotchas.j2 #}
optional_value = {{ maybe_missing }}
math_result    = {{ 5 + 5 }}
literal_braces = {% raw %}{{ this_stays_literal }}{% endraw %}
yaml
---
- name: Template gotchas
  hosts: localhost
  gather_facts: false
  tasks:
    - name: This fails - undefined variable
      ansible.builtin.template:
        src: gotchas.j2
        dest: /tmp/gotchas.txt
        mode: "0644"
      ignore_errors: true

    - name: Check the type of a templated number
      ansible.builtin.debug:
        msg: "{{ (5 + 5) | type_debug }}"
Expected result — click to reveal
plain text
TASK [This fails - undefined variable] *****************************
fatal: [localhost]: FAILED! => {
    "msg": "AnsibleUndefinedVariable: 'maybe_missing' is undefined"
}
...ignoring

TASK [Check the type of a templated number] ************************
ok: [localhost] => {
    "msg": "str"
}

Two lessons in four lines of template.

The undefined variable killed the entire file, not just that line — /tmp/gotchas.txt was never created. Add | default('unset') and it renders. This is why every optional value in a real template needs a default.

5 + 5 produced the string "10". Jinja2 arithmetic worked, then the result was stringified on the way out. So when: computed_value > 5 compares a string to an integer and behaves unpredictably — the same family of bug as the "false" string in Module 02 Part D5. Cast with | int, or enable jinja2_native.

Now fix the template and re-run to see {% raw %} working:

plain text
optional_value = unset
math_result    = 10
literal_braces = {{ this_stays_literal }}

{% raw %} passed the braces through untouched — which is how you template a file for a system that has its own {{ }} syntax.

D4 · Testing templates without deploying them

bash
# Render to stdout without touching the target
ansible localhost -m ansible.builtin.debug \
  -a "msg={{ lookup('template', 'templates/nginx.conf.j2') }}"

# Render for a REAL host, using its real facts, changing nothing
ansible-playbook site.yml --check --diff --limit web01 --tags config

# Render to a scratch path, inspect, then delete
ansible-playbook site.yml -e "nginx_conf_dest=/tmp/preview.conf" --limit web01
--check --diff is the everyday answer and shows you the exact diff against what is on the host. lookup('template', ...) is the quick one for iterating on syntax, but note it renders with localhost's facts, so a template using ansible_facts needs the real host to be meaningful.

For serious template work, Molecule (Module 11) renders against real containers and asserts on the result.

🎯 Interview questions — Production templating

Q. What does validate: do on the template module?

Runs a validation command against the temporary file before it is moved into place, substituting %s with the temp path. If validation fails, the task fails and the live file is never touched.

Use it wherever a broken config is costly: nginx -t -c %s, visudo -cf %s, sshd -t -f %s. A malformed sudoers file locks everyone out of sudo, including you.

Q. A template deploys a Helm chart that itself uses {{ }}. How?

Three options. Wrap the literal sections in {% raw %} ... {% endraw %}. Change the delimiters on the task with variable_start_string and block_start_string. Or — if nothing in the file actually needs substituting — use copy instead, which transfers bytes verbatim and never templates.

The same problem arises with Grafana dashboards, Prometheus rules and most front-end frameworks.

Q. Your template task reports changed on every run even though nothing changed. Why?

Something in the rendered content differs each time. The usual culprits: a timestamp — including a customised ansible_managed containing one; password_hash without a fixed salt; unsorted iteration over a dictionary or a set, where ordering is not stable; or a random filter without a seed.

The fix is to make the render deterministic — | dictsort or | sort on anything iterated, a fixed salt, and a static ansible_managed. Diagnose it with --diff, which shows exactly which line moved.

Q. What is jinja2_native and why would you enable it?

By default every Jinja2 expression renders to a string, so {{ 5 + 5 }} yields "10" and a list becomes its string representation. jinja2_native = True in ansible.cfg preserves real Python types — integers, lists, dictionaries, booleans.

It is genuinely useful when passing structured data between tasks, but it changes behaviour subtly across the entire project, so it is a deliberate project-wide decision rather than a quick fix. Existing playbooks relying on string coercion can break.

Q. How do you test a template without deploying it?

ansible-playbook --check --diff --limit web01 renders against the real host with its real facts and shows the exact diff, changing nothing — this is the everyday answer.

For quick syntax iteration, lookup('template', 'file.j2') in a debug task, with the caveat that it renders using localhost's facts.

For anything serious, Molecule renders against real containers and asserts on the output.


Part E · Putting it together

E1 · Production practice

HabitWhy
{{ ansible_managed }} header on every generated fileStops a colleague hand-editing it at 3am and losing the change on the next run
Keep ansible_managed static — no timestampsA timestamp makes the file differ every run, so the task reports changed forever
validate: on sshd, sudoers, nginx and anything that can lock you outTests the temp file; the live file is never touched if it is invalid
| dictsort or | sort on anything you iterateUnstable ordering makes the render differ between runs — a false changed and a needless restart
| default(...) on every optional variableOne undefined variable aborts the entire template, not just its line
Trailing | list after map, select, selectattrOtherwise you render <generator object ...> into the file with no error
| quote on any value interpolated into a shell commandShell injection — a value containing ; becomes a second command
lstrip_blocks: true when you indent your tagsOtherwise the tag indentation is emitted into the output
Prefer filters over loop-and-accumulate{% set %} in a loop does not persist — silently wrong totals
--check --diff before deploying any template changeThe rendered result is often not what you pictured

E2 · Capstone exercise

Attempt this without looking anything up. It exercises filters, loops, conditionals, whitespace control, idempotency and validation together.

Brief. Write a template that generates an nginx config for the web group, and a task to deploy it:

  1. Header marking it as Ansible-managed
  2. An upstream block listing every active host in the web group, by IP, sorted
  3. Hosts whose facts are missing must produce a comment, not a crash
  4. worker_processes set to twice the CPU count, but capped at 16
  5. TLS directives included only when enable_tls is true, defaulting to false if unset
  6. Correct indentation — no stray whitespace from the template tags
  7. Validated before going live, and must report changed=0 on a second run
Model answer — attempt it first, then click

templates/nginx.conf.j2:

javascript
# {{ ansible_managed }}
# DO NOT EDIT - generated by Ansible

{# Requirement 4: twice the CPUs, capped at 16 #}
{% set workers = [(ansible_facts['processor_vcpus'] | int) * 2, 16] | min %}
worker_processes {{ workers }};

http {
    upstream app_backend {
{% for host in groups['web'] | sort %}
{%     if hostvars[host]['ansible_facts']['default_ipv4'] is defined %}
        server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:8080 max_fails=3;
{%     else %}
        # {{ host }}: facts unavailable - gather facts for the web group first
{%     endif %}
{% endfor %}
    }

    server {
{% if enable_tls | default(false) | bool %}
        listen 443 ssl;
        ssl_certificate     {{ tls_cert | default('/etc/ssl/certs/app.pem') }};
        ssl_certificate_key {{ tls_key  | default('/etc/ssl/private/app.key') }};
{% else %}
        listen 80;
{% endif %}
        server_name {{ server_name | default('_') }};

        location / {
            proxy_pass http://app_backend;
        }
    }
}

The task:

yaml
- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: "0644"
    lstrip_blocks: true          # requirement 6
    validate: nginx -t -c %s     # requirement 7
    backup: true
  become: true
  notify: Reload nginx

The seven decisions, each mapped to a requirement:

  1. {{ ansible_managed }}, kept static — no timestamp, or requirement 7 fails on the second run.
  2. groups['web'] | sort — without it, group ordering can vary and the file differs between runs, breaking requirement 7 in a way that looks inexplicable.
  3. is defined guard — requirement 3. hostvars[host]['ansible_facts']['default_ipv4'] is undefined for any host whose facts were not gathered, and one undefined variable aborts the whole template.
  4. [x, 16] | min — the idiomatic way to express a cap. [value, ceiling] | min reads better than a nested ternary.
  5. enable_tls | default(false) | booldefault for requirement 5, and | bool because -e enable_tls=false arrives as the string "false", which is truthy. Module 02 Part D5.
  6. lstrip_blocks: true with tags indented to match the structure — requirement 6. Without it, every {% if %} indent lands in the output.
  7. validate: — requirement 7's other half. A broken nginx config that reaches disk and triggers a reload takes the site down.

Verify:

bash
ansible-playbook site.yml --check --diff --limit web01
ansible-playbook site.yml --limit web01
ansible-playbook site.yml --limit web01           # changed=0
ansible-playbook site.yml -e enable_tls=false --check --diff --limit web01

E3 · Command reference — everything from this module

Commands, filters and template options from Module 04. ⭐ marks genuinely daily-use.

Working with templates

bash
ansible-playbook site.yml --check --diff --limit web01     # ⭐ render and diff, change nothing
ansible localhost -m ansible.builtin.debug \
  -a "msg={{ lookup('template', 'templates/x.j2') }}"      # ⭐ quick syntax check
ansible-doc -t filter -l                                   # ⭐ every filter available
ansible-doc -t filter ansible.builtin.combine              # docs for one filter
ansible-doc -t test -l                                     # every test (is defined, is version)
ansible-doc ansible.builtin.template                       # ⭐ all template module options
ansible localhost -m ansible.builtin.debug \
  -a "msg={{ myvar | type_debug }}"                        # ⭐ string or int?

The filters you will use constantly

yaml
# SAFETY
| default('x')            # ⭐ fallback for undefined
| default('x', true)      # ⭐ also replaces empty/falsy - strings only
| default(omit)           # ⭐ drop the argument entirely
| mandatory               # fail loudly if missing
| bool                    # ⭐ cast - essential for anything from -e
| int  /  | float  /  | string
| quote                   # ⭐ shell-escape - SECURITY
| type_debug              # ⭐ diagnose type surprises

# STRINGS
| upper / | lower / | capitalize / | trim
| replace('a', 'b')       # ⭐
| regex_replace('^v', '') # ⭐
| regex_search('[0-9]+')
| basename / | dirname    # ⭐
| split(',') / | join(', ')   # ⭐

# LISTS AND DICTS
| length / | first / | last / | unique / | sort      # ⭐
| map(attribute='name') | list                       # ⭐ needs the trailing list
| selectattr('active') | list                        # ⭐
| rejectattr('name', 'equalto', 'root') | list
| combine(other, recursive=True)                     # ⭐ merge
| dict2items / | items2dict                          # ⭐
| dictsort                                           # ⭐ stable ordering = idempotency
| union / | intersect / | difference
| min / | max / | sum

# DATA FORMATS
| to_nice_json(indent=2) / | to_nice_yaml(indent=2)  # ⭐
| from_json / | from_yaml
| b64encode / | b64decode
| password_hash('sha512', salt)                       # needs a FIXED salt
| hash('sha256')

# MISC
| ternary('yes', 'no')    # ⭐ inline conditional
| human_readable          # 1048576 -> 1.00 MB
| urlsplit('hostname')

Template module options

yaml
- ansible.builtin.template:
    src: x.j2
    dest: /etc/x.conf
    mode: "0644"            # ⭐ always quoted
    validate: nginx -t -c %s  # ⭐ test before going live
    backup: true            # ⭐ timestamped copy of the old file
    lstrip_blocks: true     # ⭐ strip indent before block tags
    trim_blocks: true       # Ansible's default
    force: false            # write only if absent
    variable_start_string: "[["   # when the target format uses {{ }} itself

Jinja2 syntax quick reference

javascript
{{ expression }}                    {# prints a value #}
{% statement %}                     {# control flow, prints nothing #}
{# comment #}                       {# stripped from the output #}

{% for h in groups['web'] | sort %} {# ⭐ sort for idempotency #}
{{ loop.index }} {{ loop.first }} {{ loop.last }}   {# ⭐ loop.last for separators #}
{% else %}                          {# runs when the sequence is empty #}
{% endfor %}

{% if x %} {% elif y %} {% else %} {% endif %}
{% set ns = namespace(total=0) %}   {# ⭐ accumulate across loop iterations #}
{%- tag %}  /  {% tag -%}           {# ⭐ strip whitespace before / after #}
{% raw %}{{ literal }}{% endraw %}  {# ⭐ pass braces through untouched #}
{% macro name(a, b=false) %}{% endmacro %}
{% import 'macros.j2' as m with context %}

E4 · Official documentation

LinkCovers
Templating (Jinja2)How templating works in Ansible, native types, ansible_managed
Using filters to manipulate dataEvery Ansible-provided filter with examples
Testsis defined, is version, is match, is subset
Complex data manipulationmap, selectattr, subelements, json_query patterns
ansible.builtin.template moduleEvery option, including the delimiter overrides
Jinja2 template designer documentationThe upstream reference — macros, whitespace control, scoping rules
Jinja2 built-in filtersFilters that come from Jinja2 rather than Ansible
ansible.builtin.blockinfileManaging a section inside a file you do not own
Ansible's filter docs and Jinja2's are separate, and you need both. ansible-doc -t filter -l lists what is actually installed — including collection-provided filters like ansible.utils.ipaddr — while the Jinja2 site documents map, select, join and the rest of the upstream set.

E5 · Self-assessment

1. Where does a template render, and what follows from that?

On the control node. The target receives finished plain text and needs no templating library.

It follows that lookup() inside a template reads control-node files, and that a value existing only on the target must be captured with register first before a template can use it.

2. {{ }} vs {% %} vs {# #}?

Expression (prints a value), statement (control flow, prints nothing), comment (stripped entirely from the output).

3. Why does {{ users | map(attribute='name') }} render as <generator object ...>?

map is lazy and returns a generator. Add a trailing | list, or a consuming filter such as join, sort or length, which forces evaluation.

It fails silently — no error, just a Python repr string in your config file.

4. Name three causes of a template reporting changed on every run.

A timestamp in the content — including a customised ansible_managed. password_hash without a fixed salt. Unsorted iteration over a dictionary or a set, where ordering is not stable between runs. Also random without a seed.

Fix by making the render deterministic: | dictsort, | sort, a fixed salt, a static ansible_managed. Diagnose with --diff.

5. What is trim_blocks and what is Ansible's default?

It removes the first newline after a block tag. Ansible defaults it to true, unlike plain Jinja2 where it is false.

lstrip_blocks defaults to false and is the one you usually want to enable, so you can indent your tags to match the file structure without those indents appearing in the output.

6. How do you template a file that itself contains {{ }}?

{% raw %} ... {% endraw %} around the literal sections, or change the delimiters on the task with variable_start_string and block_start_string, or use copy if nothing actually needs substituting.

The common cases: Helm charts, Grafana dashboards, Prometheus rules, Vue templates.

7. Why does accumulating a total in a {% for %} loop fail?

Jinja2 scoping discards a {% set %} at the end of each iteration. Use a namespace() object, or — better in Ansible — replace the loop with a filter: {{ items | map(attribute='size') | sum }}.

8. What does validate: do, and name three places you must use it.

Runs a checker against the temporary file before it is moved into place, with %s substituted for the temp path. The task fails and the live file is untouched if validation fails.

sshd -t -f %s, visudo -cf %s, nginx -t -c %s — the three files most capable of locking you out or taking the site down.

9. What is jinja2_native?

A setting that makes Jinja2 expressions return real Python types instead of strings — so {{ 5 + 5 }} gives the integer 10 rather than "10".

Useful when passing structured data between tasks, but it changes behaviour project-wide and can break playbooks that relied on string coercion. A deliberate decision, not a quick fix.

10. template, copy, lineinfile, blockinfile — pick one for each case.

Own the file and it varies per host → template. Own it and it is static → copy. Need one line in a package-owned file → lineinfile. Need a managed section in a file you do not own → blockinfile.

The anti-pattern: many lineinfile tasks against a single file. Each is an independent regex that can drift, and it should have been a template.


Next — Module 05 · Roles & Reusability.

You now have everything a role is made of: tasks, handlers, variables, templates and control flow. Module 05 covers packaging them — directory structure, defaults vs vars, dependencies, import_role vs include_role, and Galaxy.

📚 Sources for the interview questions

Behaviour verified against the current official Ansible templating documentation and the Jinja2 template designer documentation.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.