A handler is a task defined under handlers: instead of tasks:. It only runs
when a regular task notifies it — and even then, it doesn't run immediately. Handlers
queue up and execute together at the end of the play, deduplicated.
Without handlers, every config change forces a service restart even if nothing actually changed. That's noisy, slow, and risky. With handlers:
- Five tasks edit five files in
/etc/mysql/conf.d/— service restarts once. - Nothing changes during a re-run — zero handlers fire.
- Restart happens at the end, so config validation can run between change and restart.
---
- hosts: databases
become: true
tasks:
- name: Configure my.cnf
ansible.builtin.template:
src: my.cnf.j2
dest: /etc/my.cnf
notify: restart mysql
- name: Configure logrotate
ansible.builtin.copy:
src: mysql-logrotate
dest: /etc/logrotate.d/mysql
notify: restart mysql
handlers:
- name: restart mysql
ansible.builtin.systemd:
name: mysqld
state: restarted
- name: Update TLS cert
ansible.builtin.copy:
src: certs/server.crt
dest: /etc/ssl/server.crt
notify:
- restart mysql
- restart proxysql
- reload haproxy
Handlers can subscribe to abstract events using listen — multiple handlers can
subscribe to the same event:
tasks:
- name: Update OS packages
ansible.builtin.dnf:
name: "*"
state: latest
notify: kernel updated
handlers:
- name: reload sysctl
ansible.builtin.command: sysctl -p
listen: kernel updated
- name: trigger reboot
ansible.builtin.reboot:
listen: kernel updated
# notify: kernel updated → both handlers run
| Stage | Are handlers run? |
|---|---|
End of tasks: | Yes — implicit flush |
End of roles: | Yes — between roles |
| End of the play | Yes — final flush |
| If a task fails after notify | NO — handlers DON'T run by default |
You explicitly call meta: flush_handlers | Yes — runs queued handlers right now |
Use meta: flush_handlers to drain the queue before a critical step:
tasks:
- name: Update my.cnf
ansible.builtin.template:
src: my.cnf.j2
dest: /etc/my.cnf
notify: restart mysql
- name: Force restart now (so the next task sees the new config)
ansible.builtin.meta: flush_handlers
- name: Verify MySQL is using the new max_connections
community.mysql.mysql_query:
query: "SHOW VARIABLES LIKE 'max_connections'"
register: mc
By default a failed task skips handlers. To still flush queued handlers when a later
task fails, run with --force-handlers or set force_handlers: true in
ansible.cfg:
# CLI
ansible-playbook site.yml --force-handlers
# ansible.cfg (always-on)
[defaults]
force_handlers = True
---
handlers:
# Service restarts
- name: restart mysql
ansible.builtin.systemd:
name: mysqld
state: restarted
- name: reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded # graceful — keeps workers running
# Config reloads via signal
- name: reload sysctl
ansible.builtin.command: sysctl -p
# Cache busts
- name: clear opcache
ansible.builtin.command: php -r "opcache_reset();"
# Notify external systems
- name: page on-call
ansible.builtin.uri:
url: "https://api.pagerduty.com/incidents"
method: POST
body_format: json
body: {{ event: "config-rotated" }}
changed. Instead use a regular task with changed_when: false and force=true in the systemd module.