Skip to content

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

templates/nginx_upstream.conf.j2
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.conf

Rendered 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.
Last updated on