Chapter 6: Jinja2 Templating
Topic 38

Whitespace Control and Mistakes

TemplatingPitfalls

Jinja2 keeps the whitespace around its tags, so a control tag leaves behind the newline and the indentation of the line it sat on. In plain Jinja2 a {% for %} loop renders one blank line per iteration; Ansible's template module drops that newline by default, but a loop indented to match its block still comes out with every line under it pushed out of place. Blank lines are noise in a diff, and the indentation is worse — in a YAML file it stops the parse. The fix is trim markers ({%- and -%}) and the trim_blocks / lstrip_blocks settings, which strip the newline and leading space a control tag leaves behind.

This is also the topic that collects the templating footguns that bite once a file stops being trivial: how undefined variables behave, and why a variable that happens to contain {{ }} gets re-rendered as if it were a template. None of these are exotic. They are the everyday ways a working template starts producing noisy diffs or silent wrong output, and each has a clean fix once you know it is there.

The Blank-Line Problem

In plain Jinja2, a {% for host in groups['web'] %} line, the matching {% endfor %}, and any {% if %} each leave their own newline in the output: every control-tag line that printed nothing still leaves the newline that followed it in the source, so a loop over five hosts renders five blank lines into the upstream block. Ansible's template module turns trim_blocks on by default, which removes exactly that newline, so the same loop renders tight there. The blank lines come back the moment a task sets trim_blocks: false, or the template is rendered by plain Jinja2 outside Ansible. Nginx and PostgreSQL both skip blank lines, so the cost is noisy diffs, not a broken file.

Trim Markers

{%- strips whitespace before the tag, and -%} strips it after. One dash is enough for the loop: {% for ... -%} eats the newline after the for line, so each iteration renders as one server line, with trim_blocks on or off. The dashes are surgical — you put them exactly where a stray newline is coming from, and leave the rest of the tag alone.

Why blank lines appear — plain tag vs trim markers
{% for %}
Keeps the surrounding whitespace, so with trim_blocks off (plain Jinja2) each control-tag line leaves behind its newline — one blank line per loop iteration. The template module has trim_blocks on by default and drops it.
{%- … -%}
Trims the whitespace on the dashed side of the tag. A dash after the for tag removes the stray newline; a dash on endfor as well fuses every server onto one line.
Three ways to write the loop, rendered by the template module
# no trim markers: tight with trim_blocks: true (the template module default),
# one blank line per iteration with trim_blocks: false or plain Jinja2
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_host'] }}:8000;
{% endfor %}

# a dash after the for tag: tight with trim_blocks on or off
{% for host in groups['web'] -%}
server {{ hostvars[host]['ansible_host'] }}:8000;
{% endfor %}

# a dash on endfor too: over-trimmed, every server on one line
# renders: server 10.0.1.11:8000;server 10.0.1.12:8000;
{% for host in groups['web'] -%}
server {{ hostvars[host]['ansible_host'] }}:8000;
{%- endfor %}

The danger is over-trimming. Put a dash on the endfor as well and it eats the newline at the end of each server line, so both hosts render as one fused line — the last loop in the block above. That collapses two directives onto one line and breaks the file far more thoroughly than the blank lines did. Trim markers cut both ways: under-trimming leaves noise, over-trimming fuses lines. The cure for both is to look at the rendered output, not the template, when deciding where a dash belongs.

trim_blocks and lstrip_blocks

Sprinkling a dash on every tag gets tedious, so Jinja2 offers two settings that do it wholesale. trim_blocks removes the first newline after a block tag, and lstrip_blocks strips the leading whitespace before one. Both are options on the template task, and a header line at the top of a template can override them for that one file. The module already has trim_blocks on by default, which is why a plain loop renders tight in Ansible. lstrip_blocks is off, and it is the one to turn on for config templates: with it, a tag indented to match its block no longer leaks those spaces into the output, and the lines under it keep the indentation you wrote.

An indented loop with lstrip_blocks off and on
upstream app {
    {% for host in groups['web'] %}
    server {{ hostvars[host]['ansible_host'] }}:8000;
    {% endfor %}
}

# rendered with lstrip_blocks: false (the default)
upstream app {
        server 10.0.1.11:8000;
        server 10.0.1.12:8000;
    }

# rendered with lstrip_blocks: true
upstream app {
    server 10.0.1.11:8000;
    server 10.0.1.12:8000;
}

The trade-off is that these settings change the bytes of existing templates too. Flip them on a template that quietly relied on the old whitespace and its output shifts, sometimes in ways that matter. That is not a reason to avoid them — it is a reason to flip them deliberately, with the rendered diff in front of you, rather than as an afterthought that silently rewrites a dozen config files at once.

Undefined-Variable Behavior

By default, an undefined variable raises an error at render time. That strictness is exactly what you want for config files: a typo in a variable name fails the play loudly instead of rendering a blank into app.env and letting the app boot with a missing setting. The default is your friend, and the instinct to loosen it — so a missing variable "just renders empty" — is a bug waiting to ship a half-blank config.

The right way to handle a genuinely optional value is not to rely on undefined-becomes-empty but to guard it explicitly with default. {{ optional_setting | default('off') }} says out loud what the missing-value behavior is, where depending on silent emptiness leaves it implicit and fragile. Strict by default, explicit defaults where you mean them — that combination keeps a missing variable from ever silently reaching a live file.

Recursive Templating

Ansible re-renders a variable whose value itself contains {{ }}. That is usually a feature: a group_vars value like base_url: "https://{{ domain }}" resolves correctly because Ansible templates the value before using it. The problem is when a value contains literal braces it did not mean as template syntax — a regex like {{2,4}}, a Prometheus query, a password that happens to contain {{. Ansible tries to evaluate it as a template and either fails or mangles the value.

The fix is to mark the value as not-to-be-templated. Wrap it in {% raw %}...{% endraw %} in a template, or tag it !unsafe in a vars file, and Ansible leaves the braces alone. This is the last footgun in the chapter and the most surprising, because the data looks fine until the one run where a password with the wrong character in it brings down a deploy with an error about an undefined template variable that you never wrote.

Common Mistakes
  • Indenting {% for %} and {% endfor %} to match the block they sit in while lstrip_blocks is off — the tags' spaces stay in the output, so the server lines render at double indentation and the line after the loop is shifted too, which nginx shrugs off and a YAML file refuses to parse.
  • Putting -%} on a line where you actually wanted the trailing newline, collapsing two config directives onto one line and breaking the file — trim markers cut both ways, and over-trimming is as broken as under-trimming.
  • Depending on an undefined variable rendering as empty instead of guarding it, so a typo'd variable name silently produces a blank value in app.env and the app starts with a missing setting.
  • Storing a value that contains literal {{ }} — a password, a regex like {{2,4}}, a Prometheus query — in a variable and watching Ansible try to template it; wrap it in {% raw %} / {% endraw %} or mark it !unsafe.
  • Flipping trim_blocks or lstrip_blocks on every template task in a role and being surprised a template that relied on the old whitespace now renders differently — whitespace settings change existing output, so flip them with the rendered diff in front of you.
Best Practices
  • Leave trim_blocks at the template module's default of true and turn on lstrip_blocks for config templates, so loops and conditionals produce clean, correctly indented output without a trim marker on every tag, and reserve explicit {%- -%} for the spots that need finer control.
  • Keep undefined-variable strictness on (the default) and guard genuinely optional values with | default(...), so a missing variable fails loudly rather than rendering a silent blank into a live config.
  • Wrap any value containing literal {{ }} — passwords, regexes, query languages — in {% raw %} / {% endraw %} or !unsafe, so Ansible does not try to re-template data that only looks like a template.
  • Diff the rendered output with --check --diff against the template task when changing whitespace settings or trim markers, since these changes alter the file's bytes and you want to see exactly what moved.
  • Make the call about where a dash belongs by looking at the rendered file, not the template, so you trim the actual stray newline instead of guessing at it from the source.
Comparable tools Go templates {{- / -}} trim markers solve the identical problem in Helm and Consul-template Terraform templatefile() shares the same Jinja-like whitespace concerns ERB -%> trim mode, the Chef and Puppet equivalent

Knowledge Check

What do trim_blocks and lstrip_blocks each strip?

  • trim_blocks removes the first newline after a block tag; lstrip_blocks strips the leading whitespace before one
  • Both of them indiscriminately remove every blank line from the rendered output file, regardless of which tags produced them
  • trim_blocks trims the whitespace around {{ }} expressions, while lstrip_blocks trims it around {% %} statement tags
  • They strip whitespace from the source template file itself, not the rendered output

Why is the default undefined-variable behavior (raise an error) desirable for config files?

  • A typo'd variable name fails the play loudly instead of silently rendering a blank into a live config
  • It makes templates render measurably faster by skipping over any variables that are unset
  • It lets the managed node step in and fill in sensible defaults for any template values that were left missing on the control node
  • It prevents the control node from reading any optional variables into the template at all

A variable holds a password that contains literal {{ characters. What happens, and how do you stop it?

  • Ansible tries to re-template the value and fails or mangles it; wrap it in {% raw %}/{% endraw %} or mark it !unsafe
  • Ansible detects and escapes the embedded braces automatically before rendering, so nothing extra needs to be done about it
  • The value renders perfectly fine on the control node, but the managed node then rejects it on receipt as a malformed config entry
  • The braces are stripped out silently before delivery, leaving a truncated and unusable password in the rendered config file

You are about to turn on lstrip_blocks for an existing role. What is the safe way to verify the change?

  • Run the template task with --check --diff first, to see exactly which bytes moved before applying it
  • Trust the setting and apply it straight to production, since whitespace never affects valid configs
  • Re-render the file on the managed node and compare the resulting hostnames
  • Delete the live config first so there is a clean slate with nothing to diff against

You got correct