Introduction
Handlers are tasks that only run when notified by another task that made a change. The classic use case: restart a service only when its config file changes. Without handlers, you'd restart the service on every playbook run — wasteful and potentially disruptive.
Basic Syntax
---
- name: Deploy web server
hosts: webservers
become: true
tasks:
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx # ← triggers handler only if config changed
- name: Deploy site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
notify: Reload nginx # ← reload is lighter than restart
handlers:
- name: Restart nginx # ← runs at END of play, only if notified
ansible.builtin.service:
name: nginx
state: restarted
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
How Handlers Work
- A task runs and reports
changed: true - If that task has
notify:, the named handler is queued - Handlers run once at the end of the play (not immediately)
- If multiple tasks notify the same handler, it still runs only once
- If no task notified a handler, it doesn't run at all
Task 1 (changed) → notify: Restart nginx ← queued
Task 2 (ok) → notify: Restart nginx ← NOT queued (no change)
Task 3 (changed) → notify: Restart nginx ← already queued
Task 4 (changed) → notify: Reload app ← queued
─── end of tasks ───
Handler: Restart nginx ← runs ONCE
Handler: Reload app ← runs ONCE
Notify Multiple Handlers
tasks:
- name: Deploy app config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/myapp/config.yml
notify:
- Restart myapp
- Clear cache
- Send deploy notification
handlers:
- name: Restart myapp
ansible.builtin.service:
name: myapp
state: restarted
- name: Clear cache
ansible.builtin.command: /opt/myapp/bin/clear-cache
- name: Send deploy notification
ansible.builtin.uri:
url: "https://hooks.slack.com/services/XXX"
method: POST
body_format: json
body:
text: "Config updated on {{ inventory_hostname }}"
Flush Handlers (Run Immediately)
By default, handlers run at the end. Use meta: flush_handlers to run them mid-play:
tasks:
- name: Deploy config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/myapp/config.yml
notify: Restart myapp
- name: Force handlers to run NOW
ansible.builtin.meta: flush_handlers
- name: Verify service is running (after restart)
ansible.builtin.uri:
url: "http://localhost:8080/health"
status_code: 200
retries: 5
delay: 3
This is critical when subsequent tasks depend on the service being restarted.
listen (Group Handlers)
listen lets multiple handlers respond to a single notification topic:
tasks:
- name: Deploy application
ansible.builtin.copy:
src: app.tar.gz
dest: /opt/app.tar.gz
notify: "deploy completed" # ← topic name
handlers:
# Both handlers respond to "deploy completed"
- name: Restart application
ansible.builtin.service:
name: myapp
state: restarted
listen: "deploy completed"
- name: Clear CDN cache
ansible.builtin.uri:
url: "https://api.cdn.com/purge"
method: POST
listen: "deploy completed"
- name: Send notification
ansible.builtin.uri:
url: "https://hooks.slack.com/services/XXX"
method: POST
body_format: json
body:
text: "Deploy completed on {{ inventory_hostname }}"
listen: "deploy completed"
Handlers in Roles
roles/nginx/
├── tasks/
│ └── main.yml
└── handlers/
└── main.yml ← handlers defined here
roles/nginx/handlers/main.yml:
---
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
roles/nginx/tasks/main.yml:
---
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx # References handler from handlers/main.yml
Handler Ordering
Handlers run in the order they're defined in the handlers section, not the order they're notified:
handlers:
- name: Stop app # Runs first (defined first)
ansible.builtin.service:
name: myapp
state: stopped
- name: Run migrations # Runs second
ansible.builtin.command: /opt/myapp/bin/migrate
- name: Start app # Runs third (defined last)
ansible.builtin.service:
name: myapp
state: started
Practical Patterns
Config Deploy with Validation
tasks:
- name: Deploy sshd config
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
validate: "sshd -t -f %s"
notify: Restart sshd
- name: Deploy sudoers
ansible.builtin.template:
src: sudoers.j2
dest: /etc/sudoers
validate: "visudo -cf %s"
# No handler needed — sudoers takes effect immediately
handlers:
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
Multiple Config Files, One Restart
tasks:
- name: Deploy main config
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
loop:
- { src: app.conf.j2, dest: /etc/myapp/app.conf }
- { src: db.conf.j2, dest: /etc/myapp/db.conf }
- { src: cache.conf.j2, dest: /etc/myapp/cache.conf }
notify: Restart myapp # Only restarts ONCE even if all 3 change
handlers:
- name: Restart myapp
ansible.builtin.service:
name: myapp
state: restarted
Troubleshooting
Handler Not Running
Cause 1: The notifying task didn't change anything (ok instead of changed):
# Check task output — must show "changed: true"
TASK [Deploy config] *****
ok: [web-01] # ← No change, handler NOT triggered
changed: [web-01] # ← Changed, handler IS triggered
Cause 2: Handler name doesn't match:
# WRONG — names don't match
notify: restart nginx # lowercase
# ...
- name: Restart nginx # capitalized — DOESN'T MATCH (case-sensitive!)
Cause 3: Play failed before handlers ran. Use --force-handlers:
ansible-playbook site.yml --force-handlers
Or in ansible.cfg:
[defaults]
force_handlers = True
Handler Runs Too Late
Use meta: flush_handlers to run immediately (see above).
Related Articles
Conclusion
Handlers prevent unnecessary service restarts by only running when a task reports a change. Key rules: handlers run once at the end of the play (use flush_handlers to run earlier), the handler name must match the notify string exactly (case-sensitive), and multiple notifications to the same handler result in a single execution. Use listen to group multiple handlers under one topic. Use --force-handlers to ensure handlers run even if later tasks fail.