Templating & handlers¶
Scope: the course page renders one Jinja2 template with an
if/forblock and wires onetemplate:task to one handler vianotify:. This page slows down on the Jinja2 control-structure vocabulary beyond that one example, how a handler queue actually resolves when several tasks notify the same handler (or several handlers share one notification), and what--check/--diffreally show you — including why a handler can report "would restart" without ever actually restarting anything.
TL;DR — in 30 seconds:
- Handlers de-duplicate: three tasks notifying the same handler still fire it once, at the end of the play — and handlers run in the order they're defined, not the order they were notified.
--check --diffis the closest thing Ansible has toterraform plan— butcommand:/shell:tasks are skipped by default in check mode (they can't honestly predict their own result), and handlers never run in check mode, even if their task reportschanged.{%-/-%}trim whitespace around a Jinja2 tag — without them, every block tag leaves its own newline in the rendered output, invisible in a forgiving config format but visible as broken indentation in a strict one.
1. Jinja2 control structures — the vocabulary the course page doesn't use¶
The course template uses one {% if %} and one {% for %}. Both are part of a small, fixed set of
block tags — there is no larger "advanced Jinja2" to learn beyond combining these:
| Tag | Does |
|---|---|
{% if cond %}...{% elif cond %}...{% else %}...{% endif %} |
Conditional output |
{% for item in seq %}...{% endfor %} |
Loop over a list or dict |
{% set name = value %} |
Assign a template-local variable |
{% macro name(args) %}...{% endmacro %} |
Define a reusable snippet (below) |
{% include 'other.j2' %} |
Inline another template file at this point |
Inside a {% for %}, the special loop object tells you where you are without any manual counter:
{% for backend in upstream_servers %}
server {{ backend.host }}:{{ backend.port }};{{ "" if loop.last else "" }}
{# loop.index is 1-based, loop.index0 is 0-based, loop.first / loop.last are booleans #}
{% endfor %}
Whitespace control is the detail that produces "why does my rendered file have blank lines / extra
indentation" the first time someone hits it. A - inside a tag delimiter trims whitespace on that side:
{% for h in hosts -%}
{{ h }}
{%- endfor %}
{%- trims whitespace before the tag; -%} trims whitespace after it. Without them, every block tag
leaves its own line's newline in the output — harmless in a config file with forgiving whitespace, visible
as broken indentation in one that isn't (YAML, some .conf formats).
2. Macros — a template's version of a role¶
A macro is a parameterized, reusable block inside (or imported into) a template — the templating equivalent of factoring repeated task logic into a role:
{% macro upstream_block(name, servers) %}
upstream {{ name }} {
{% for s in servers %}
server {{ s.host }}:{{ s.port }};
{% endfor %}
}
{% endmacro %}
{{ upstream_block('web_backends', upstream_servers) }}
{{ upstream_block('api_backends', api_servers) }}
A macro defined in one file is reused from another with {% import %} (bind the whole file under a
namespace) or {% from … import %} (pull in one macro by name) — the same in-file-vs-shared distinction
as a role's local defaults/ versus a collection you install once and call from many playbooks.
3. Filters — chain them, don't nest function calls¶
The course page's filters (default, to_yaml, b64encode, …) each take one value and transform it;
filters chain left to right with |, which reads better than nesting:
{{ site_extra_headers | default({}) | dictsort | map('join', ': ') | list }}
Two filters worth knowing beyond the course page's list because they solve the specific "the variable might not exist" and "I need this as a list of lines" problems that come up in real templates:
| Filter | Use for |
|---|---|
mandatory |
Raise a clear error immediately if the variable is undefined, instead of rendering a blank and failing later downstream |
dictsort |
Iterate a dict's items in a stable, sorted order (plain .items() order isn't guaranteed across Python/Jinja versions) |
4. Handlers — the queue, not just the trigger¶
The course page shows one task notifying one handler. Two mechanics govern what actually happens once there's more than one of either:
- A handler notified by multiple tasks still runs once. If three tasks in a play each
notify:the same handler name, Ansible de-duplicates — the handler fires a single time, at the end of the play, not once per notifying task. - Handlers run in the order they're defined, not the order they were notified — Ansible's docs are
explicit on this.
If task A (which appears first) notifies handler "Reload nginx" and task B (which runs later) notifies
handler "Restart app", but
handlers:lists "Restart app" before "Reload nginx", "Restart app" runs first — the notification order is irrelevant.
flowchart TB
T1["Task 1: notify Reload nginx"]
T2["Task 2: notify Reload nginx"]
T3["Task 3: notify Restart app"]
Q["End-of-play handler queue\n(deduplicated, in handlers: order)"]
H1["Restart app — runs once"]
H2["Reload nginx — runs once"]
T1 --> Q
T2 --> Q
T3 --> Q
Q --> H1
Q --> H2
listen: topics invert the one-name-per-handler assumption: several handlers can all listen: to the
same topic string, and a single notify: of that topic queues every handler listening to it. This is the
pattern for "one config change should restart both the app and its sidecar" without a task having to know
both handler names individually:
handlers:
- name: Restart app
ansible.builtin.service: { name: app, state: restarted }
listen: "app config changed"
- name: Restart sidecar
ansible.builtin.service: { name: app-sidecar, state: restarted }
listen: "app config changed"
tasks:
- name: Render app config
ansible.builtin.template: { src: app.conf.j2, dest: /etc/app/app.conf }
notify: "app config changed"
5. --check / --diff — what they actually inspect¶
--check runs the play without making changes; --diff additionally prints a unified diff for any module
that supports showing one (template, copy, lineinfile, blockinfile — most file-content modules).
Combined, ansible-playbook site.yml --check --diff is "show me exactly what would change and how,
without touching anything" — the closest thing Ansible has to a terraform plan.
Two things worth knowing about how check mode actually behaves, not just how to invoke it:
- Not every module can honestly predict its own result in check mode. A module built on Ansible's
standard file/package primitives simulates its change and reports it; a
command:/shell:task has no way to know what its underlying command would do without running it, so by default it's skipped in check mode — by design, not a gap. If a customcommand:/shell:task must participate in check-mode planning, it needs an explicitcheck_mode: false(always run it, even in check mode) or awhen: not ansible_check_modeguard — the same variable a template can reference too. - Handlers don't run in check mode, even if their notifying task reports
changed. This follows directly from what check mode means: the task didn't really apply its change, so there's nothing for the handler to react to. A--check --diffrun tells you a handler would fire; it never actually fires it.
6. How this connects¶
The course page's one {% if %}/{% for %} template and one notify: → one handler is the smallest
version of both systems: no macros, no listen: topics, no de-duplication to reason about, and a
single-task check-mode story. This page is what to reach for once a template grows past one condition and
one loop, once more than one task can notify the same restart, or once you need to trust a --check --diff
run against production instead of just running --check and hoping.
Common gotchas
- Assuming a handler fires immediately after the task that notified it. Fix: handlers queue and
fire once at the end of the play, in
handlers:definition order — not notify order, and not mid-play unless you explicitlymeta: flush_handlers. - Trusting a
--check --diffrun to show what a handler would do. Fix: handlers never run in check mode at all, even when the notifying task reportschanged— check mode tells you a handler would fire, never what it would actually do. - Writing a
command:/shell:task and expecting--checkto validate it like a real module. Fix: Ansible can't predict what an arbitrary command would do without running it, so these are skipped by default in check mode — addcheck_mode: falseor awhen: not ansible_check_modeguard if it must participate. - Nesting Jinja2 filter calls instead of chaining them. Fix: filters chain left-to-right with
|(value | default({}) | dictsort) — nesting reads worse and is easy to get the argument order wrong on.
Check yourself — three separate tasks in a play each notify: a handler named 'Restart app'. How many times does it actually run, and when?
Once — at the end of the play, regardless of how many tasks notified it. Ansible de-duplicates notifications to the same handler name before running the end-of-play handler queue.
Check yourself — you run ansible-playbook site.yml --check --diff and see a task report changed, with a handler it notifies listed as would-run. Did the handler's restart actually happen?
No — handlers never execute in check mode, even when their notifying task reports changed. --check
--diff shows you what would happen on a real run; it never actually fires the handler.
You can defend this when you can say why {%-/-%} change a template's rendered whitespace, explain
why three tasks notifying the same handler still produces one restart and why handler definition order
(not notify order) decides run order, describe what listen: buys you over naming the same handler
directly, and state which category of task (command/shell) check mode skips by default and why.