Template Module Control Structures
Every Jinja2 example in this course so far has been a single {{ }} expression — one value, resolved and printed. A real config file often needs more than that: a repeated block, one line per item in a list, or a whole section that only appears under certain conditions. That needs a different piece of Jinja2 syntax entirely.
{{ }} vs. {% %}: Expressions vs. Statements
{{ }} is for expressions — a single value to output, with any filters or tests applied. {% %} is for statements — control structures that don’t directly output anything themselves, but control how the surrounding template content gets generated. Loops and conditionals inside a template both use {% %}, not {{ }}.
{% for %} — Repeating A Block For Each List Item
{% for server in upstream_servers %}
server {{ server }};
{% endfor %}Every {% for %} needs a matching {% endfor %} — this repeats the line between them once per item in upstream_servers, with {{ server }} resolving to that iteration’s value each time, exactly the way {{ item }} worked inside a playbook’s own loop:.
A Realistic Example: A List Of Upstream Servers
upstream backend {
{% for server in upstream_servers %}
server {{ server }};
{% endfor %}
}vars:
upstream_servers:
- 10.0.0.1:8080
- 10.0.0.2:8080
- 10.0.0.3:8080
tasks:
- name: Generate upstream config
ansible.builtin.template:
src: nginx_upstream.conf.j2
dest: /etc/nginx/upstream.confRendered output:
upstream backend {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
server 10.0.0.3:8080;
}This is genuinely something {{ }} alone could never do — there’s no way to loop inside a single expression the way a real {% for %} block can.
{% if %} — Conditionally Including A Section
{% if enable_ssl %}
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
{% endif %}{% endif %} closes it, and {% else %} is available too, for an alternate section when the condition is false.
Combining {% for %} And {% if %}
Nesting works exactly as you’d expect — filtering which iterations actually produce output:
{% for server in upstream_servers %}
{% if server.enabled %}
server {{ server.address }};
{% endif %}
{% endfor %}Every server in the list is visited, but only the ones with enabled: true actually produce a line in the rendered output.
Whitespace Control: {%- -%}
By default, a {% %} tag sitting on its own line still leaves its own newline behind in the output, even though the tag itself produces no visible content — this often shows up as unexpected blank lines in a rendered config. The dash variants, {%- and -%}, trim the adjacent whitespace:
{%- for server in upstream_servers %}
server {{ server }};
{%- endfor %}If a rendered file has more blank lines than you expected, this is almost always why — and adding the trimming dashes to the relevant tags is the fix.
A Gotcha: Forgetting {% endfor %} Or {% endif %}
A missing closing tag isn’t caught until the template is actually rendered — the template task fails at that point with a syntax error, not any earlier. This is an easy mistake in a longer template with several nested loops and conditionals, where it’s genuinely easy to lose track of which {% for %} or {% if %} still needs its matching close.
Best Practices
- Use
{{ }}for single-value expressions,{% %}for control structures — keep this distinction sharp, since they solve genuinely different problems inside a template. - Always close every
{% for %}/{% if %}with its matching{% endfor %}/{% endif %}— a missing one fails at render time, when the template task actually runs, not before. - Reach for whitespace-trimming dashes the moment a rendered file has unexpected blank lines — it’s almost always a tag’s own leftover newline, not a mistake in your data.