Skip to content

Templating & handlers

Scope: the course page renders one Jinja2 template with an if/for block and wires one template: task to one handler via notify:. 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/--diff really 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 --diff is the closest thing Ansible has to terraform plan — but command:/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 reports changed.
  • {%-/-%} 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 notifiedAnsible'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 custom command:/shell: task must participate in check-mode planning, it needs an explicit check_mode: false (always run it, even in check mode) or a when: not ansible_check_mode guard — 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 --diff run 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 explicitly meta: flush_handlers.
  • Trusting a --check --diff run to show what a handler would do. Fix: handlers never run in check mode at all, even when the notifying task reports changed — check mode tells you a handler would fire, never what it would actually do.
  • Writing a command:/shell: task and expecting --check to 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 — add check_mode: false or a when: not ansible_check_mode guard 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.