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

  1. A task runs and reports changed: true
  2. If that task has notify:, the named handler is queued
  3. Handlers run once at the end of the play (not immediately)
  4. If multiple tasks notify the same handler, it still runs only once
  5. 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).

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.