AnsibleXVI · HandlersHandlers
Handlers as a safety mechanism
What you'll learn
- Explain why a handler is a change gate rather than a convenience
- Write a notify that fires only when the configuration genuinely changed
- Diagnose a service that restarts on every run as an idempotency defect upstream
- Recognise that a handler which never fires is the more dangerous failure
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A handler is a task that runs only if some other task reported
changed. That is the entire mechanism. It is one sentence, and it is
the most operationally consequential sentence in the playbook language,
because the alternative — restarting the service every time the play
runs — is a decision most automation makes by accident.
The idempotency part established that changed is a claim about
whether the host was modified, and that Ansible computes it per task and
per host. Handlers are what that claim is for. Everything downstream
of an accurate changed is built on this one wire.
What the conditional buys you
Consider a play that manages an nginx configuration across 40 web servers, run every four hours from a scheduler. On a typical run, zero hosts have a configuration change. Occasionally one or two do, because somebody merged a change to a template.
Without a handler, the play has two options and both are bad:
- Always restart. 40 servers drop their in-flight connections every
four hours, six times a day, forever. Connection pools reset, caches
go cold, and if the fleet has no
serialthey all do it inside the same few seconds. - Never restart. New configuration sits on disk and the running process keeps serving the old one, indefinitely.
With a handler, the play restarts exactly the hosts whose configuration actually changed, and only on the runs where it changed. On the vast majority of runs, that is zero hosts and zero restarts.
- name: Manage the reverse proxy configuration
hosts: webservers
become: true
tasks:
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
owner: root
group: root
mode: '0644'
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.systemd_service:
name: nginx
state: reloadedRead the severity badge on that block carefully. The block is
SERVICE-IMPACT because it can reload nginx, not because it always
will. On a converged fleet the same play is a no-op with an ok for
every host, and that difference is the whole point.
The gate is only as good as the changed beneath it
notify tests result['changed'] after changed_when has had its say.
It does not test whether the file is different, whether the service is
running, or whether anything sensible happened. It tests one boolean
that the task produced.
That means a handler cannot be more accurate than the task notifying it, and the two failure directions are symmetrical:
| Upstream defect | What the handler does | What it looks like in production |
|---|---|---|
Task always reports changed | Handler fires every run | Service restarts on every scheduled run, on every host, forever |
Task never reports changed | Handler never fires | New configuration on disk, old configuration in memory, run reports green |
Both are handler symptoms with non-handler causes. The instinct when a service restarts nightly is to look at the handler. The handler is almost always correct; the notifying task is almost always the defect.
$ ansible-playbook -i inventories/prod site.yml --limit webserversTASK [Deploy the site configuration] *******************************************
ok: [web01.example.com]
ok: [web02.example.com]
ok: [web03.example.com]
PLAY RECAP *********************************************************************
web01.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The absence of a RUNNING HANDLER line is the signal. There is no
“handler skipped” message, no counter for notifications that did not
happen, and nothing anywhere in the output stating that a notify
existed. A handler that did not run leaves no trace of any kind.
Notify from the task that changes the thing
The single most common structural mistake is attaching notify to the
wrong task.
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
mode: '0644'
- name: Check the configuration parses
ansible.builtin.command: nginx -t
notify: Reload nginxnginx -t runs every time and reports changed every time, because
command has no way to know it changed nothing. So the handler fires on
every run of the play — and, worse, it fires whether or not the
template task did anything. The notification is now completely
decoupled from the change it is supposed to represent.
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
mode: '0644'
notify: Reload nginx
- name: Check the configuration parses
ansible.builtin.command: nginx -t
changed_when: falseTwo rules follow from this, and they cover most handler design:
notifybelongs on the task that owns the resource — thetemplate,copy,lineinfileorpackagetask that actually modifies the thing the service reads.- Read-only probes get
changed_when: falseso they cannot contaminate the signal. This is the correct, narrow use of that keyword; the blanket application of it to writing tasks is the defect the idempotency part warned about.
The blast-radius shape of a handler
A handler is scoped per host. If three of 40 web servers had a configuration change, three servers reload and 37 do not. That precision is a real operational benefit and it is worth naming explicitly, because it is the opposite of what a shell loop over the fleet would do.
But precision is not the same as safety. On the run where a template
variable changes for every host — a new upstream address, a TLS
setting, a tuning parameter in group_vars — all 40 hosts report
changed and all 40 handlers fire. With the default linear strategy
and default forks, those reloads are as close to simultaneous as the
controller can make them.
That is a fleet-wide service event triggered by a correct handler on a correct play. Lesson 6 of this part returns to it, and the serial and rolling parts are where it is actually solved.
Knowledge check
Knowledge check · 4 questions
Q1. A play manages nginx config on 40 servers and runs every four hours. On most runs nothing has changed. What does a correctly wired handler produce on those runs?
Q2. A team attaches notify: Reload nginx to a command task running nginx -t, placed after the template task. What is the consequence?
Q3. Which statements about notify are true? Select all that apply.
Q4. A handler cannot be more accurate than the changed result of the task that notifies it.
Passing score: 75%. Answers are checked in this browser.