Skip to main content
RunBook Academy

AnsibleXVI · HandlersHandlers

Handlers as a safety mechanism

Intermediate⏱ ~18 minansible-playbook

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

Not yet marked complete on this device.

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 serial they 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.

Service impact possiblethe shape
- 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: reloaded

Read 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 defectWhat the handler doesWhat it looks like in production
Task always reports changedHandler fires every runService restarts on every scheduled run, on every host, forever
Task never reports changedHandler never firesNew 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.

Read-only / Safewhat a converged run looks like
$ ansible-playbook -i inventories/prod site.yml --limit webservers
TASK [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=0

The 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.

Service impact possiblewrong
- 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 nginx

nginx -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.

Service impact possibleright
- 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: false

Two rules follow from this, and they cover most handler design:

  1. notify belongs on the task that owns the resource — the template, copy, lineinfile or package task that actually modifies the thing the service reads.
  2. Read-only probes get changed_when: false so 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

  1. 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?

  2. Q2. A team attaches notify: Reload nginx to a command task running nginx -t, placed after the template task. What is the consequence?

  3. Q3. Which statements about notify are true? Select all that apply.

  4. 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.