Chapter 6: Jinja2 Templating
Topic 33

Jinja2 Fundamentals

ConceptTemplating

Jinja2 is the templating engine Ansible uses everywhere a value can vary. The template module renders it into config files, but it does far more than that: when: conditions, loop expressions, and nearly every string in a playbook run through Jinja2 too. There are two delimiters to learn. {{ expr }} substitutes the value of an expression into the output, and {% ... %} runs control flow — if, for, set — without printing anything itself.

Once you see that {{ }} means "print this" and {% %} means "do this," the syntax stops being magic and becomes two tools with one job each. Everything in this chapter — the template module, filters, tests, lookups — is built on those two delimiters and one fact that catches everyone off guard: the rendering happens on the control node, not on the server the file lands on.

The Two Delimiters

{{ ansible_hostname }} prints the host's name into the output. {% if env == "prod" %} makes a decision and renders nothing on its own — it gates whatever sits between the if and its endif. {# this never reaches the file #} is a comment that vanishes during rendering. Those three forms make up every template you will ever write: print a value, run logic, leave a note. There is nothing else to the syntax.

The split matters because the two forms are not interchangeable. A {{ }} that wraps an if is a syntax error, and a {% %} that you expect to emit a value renders empty. The mental model is mechanical: braces-with-braces is an expression that resolves to text, brace-percent is a statement that controls flow. Keep them in separate boxes in your head and the templates read cleanly.

The two Jinja2 delimiters — one job each
{{ expression }}
Substitutes a value into the output. {{ larkspur_server_name }} resolves to text and prints it into the rendered file.
{% statement %}
Runs control flow — if, for, set — and prints nothing itself; it gates or repeats whatever sits inside it.
The three Jinja2 forms in larkspur.conf.j2
# {{ }} prints a value into the rendered file
server_name {{ larkspur_server_name }};

# {% %} runs logic and prints nothing itself
{% if env == "prod" %}
access_log /var/log/nginx/larkspur.access.log;
{% endif %}

# {# #} is a comment that never reaches the output file
{# bump worker_connections when we add the third web host #}

Variables and Facts Inside Templates

Anything in scope on the host is reachable inside a template. A group_vars value like nginx_worker_processes, a result you registered earlier in the play, or a gathered fact like ansible_processor_vcpus are all just names you can drop into a {{ }}. That is what lets a template size itself to the machine it lands on — the same larkspur.conf.j2 can set worker_processes to the host's actual vCPU count rather than a hard-coded guess that is wrong on half the fleet.

Facts are where templating earns its keep, because they describe the real machine, not your assumptions about it. A template that reads ansible_default_ipv4.address binds nginx to whatever IP the host actually has, and one that reads ansible_distribution branches on the OS without you tracking which box runs what. The catch is that a fact only exists if facts were gathered — reference one on a play that ran with gather_facts: false and the variable is simply undefined.

Where Templating Happens

Jinja2 is not confined to .j2 files. A when: env == "prod" condition is implicitly templated, a msg: "Deploying {{ release }}" in a debug task is templated, and a vars: value can reference another variable, which Ansible resolves by running it through Jinja2. The engine runs across most string values in a playbook, which is the real reason a stray {{ in an unexpected place — a password, a regex — gets evaluated when you did not mean it to.

This is liberating once it clicks: you do not learn one templating system for files and a different one for conditions. It is Jinja2 everywhere, the same delimiters and the same rules. The flip side is that "everywhere" includes places you forget about, which is why a value that happens to contain template syntax can blow up a task that has nothing to do with templates — a footgun the final topic of this chapter takes apart.

Bare vs Braced Expressions

A when: takes a bare expression — when: app_enabled, no braces — because the field is already a Jinja2 expression context. A string value, by contrast, needs {{ }} to escape out of literal text and into expression mode: msg: "{{ release }} deployed". Mixing the two conventions is the first syntax confusion every newcomer hits, usually by wrapping a when: in braces and getting a warning they do not understand.

The rule is about context. Fields that Ansible already evaluates as expressions — when:, failed_when:, changed_when: — want the bare form, and adding {{ }} is redundant at best. Fields that hold ordinary strings want the braces to mark which part is an expression. When in doubt, ask whether the field is a condition or a string: conditions go bare, strings get braced.

Rendering Happens on the Control Node

The template module reads the .j2 file, renders it against the host's variables right there on your laptop or CI runner, and then copies the finished file to the managed node. The server never sees Jinja2 — only the rendered result, a plain config file with every {{ }} already resolved to text. The managed node has no templating engine and needs none; by the time the file arrives, all the logic has already run.

This is the single most important fact in the chapter, and it is invisible until topic 37. As long as your templates read variables and facts about the target host, the distinction does not bite — Ansible has those values on the control node. The moment you reach for a lookup, which reads a file or environment variable, "on the control node" stops being trivia and starts being the reason lookup('file', '/etc/hostname') returns your laptop's hostname, not the server's.

Common Mistakes
  • Wrapping a when: condition in braces — when: "{{ app_enabled }}" works by accident but Ansible warns, because when: is already an expression context; write the bare form when: app_enabled.
  • Expecting {% if %} to print something — control tags render nothing on their own; the value has to come from a {{ }} inside or after the block, and forgetting that yields a config file full of logic but with no output.
  • Referencing a fact like ansible_default_ipv4.address in a template when gather_facts never ran, so the variable is undefined and the render either fails or emits an empty string into a live config.
  • Assuming the managed node interprets the template — it does not; rendering finishes on the control node before the file is sent, so a {{ }} that depends on control-node state behaves differently from one depending on the target.
  • Treating .j2 files as the only place Jinja2 runs, then being surprised when a vars: value or a msg: string with a stray {{ gets evaluated and breaks a task that has nothing to do with templating.
Best Practices
  • Keep templates as data-with-holes, not programs — a .j2 heavy with nested {% if %} logic is a sign the decision belongs in the playbook or in a computed variable, not the template.
  • Reference facts by their exact path and confirm the host gathered facts, so a render never quietly depends on a variable that is not actually there.
  • Use {# #} comments to explain non-obvious logic inside a template, since the rendered file will not carry them and the next engineer reads the .j2, not the output.
  • Write when: and other condition fields as bare expressions, reserving {{ }} for the places that genuinely inject a value into a surrounding string.
  • Hold onto the fact that rendering runs on the control node — it costs nothing now and saves an afternoon of confusion the first time you reach for a lookup.
Comparable tools Terraform templatefile() with ${ } interpolation, the provisioning-side analog Go templates behind Helm and Consul-template ERB the templating layer in Puppet and Chef

Knowledge Check

What is the difference between {{ }} and {% %} in a Jinja2 template?

  • {{ }} prints the value of an expression into the output, while {% %} runs control flow and renders nothing itself
  • {{ }} is reserved for gathered facts like ansible_hostname while {% %} is reserved for ordinary user variables set in group_vars or vars
  • {{ }} is evaluated on the managed node after delivery while {% %} runs back on the control node during the render
  • They are fully interchangeable, and the choice between the two is purely a matter of personal style convention

Why does a when: condition take a bare expression like when: app_enabled instead of when: "{{ app_enabled }}"?

  • The when: field is already evaluated as a Jinja2 expression, so the braces are redundant and Ansible warns about them
  • Braces are forbidden anywhere outside an actual .j2 template file on disk, so a task field like when: can never legally contain them
  • The bare form evaluates measurably faster because it skips the Jinja2 templating engine on the field entirely and reads the value directly
  • when: only accepts a literal true or false, never a variable reference

Where does a template task physically render the .j2 file?

  • On the control node, against the host's variables; only the finished file is then copied to the managed node
  • On the managed node, which runs its own copy of the Jinja2 engine over the received template
  • Half on each — variable substitution resolves on the control node and the loop logic resolves on the target
  • Inside a temporary container spun up between the two hosts purely for isolation

A template references ansible_default_ipv4.address but the play ran with gather_facts: false. What happens?

  • The variable is undefined, so the render fails or emits an empty string where the address should be
  • Ansible quietly gathers that one fact on demand from the host to satisfy the template reference at render time
  • The managed node substitutes its own IP into the file once it receives the rendered output
  • The template silently falls back to the loopback address 127.0.0.1 for the field

You got correct