Ansible Handlers Explained: Restart Services Only When Tasks Change

Learn Ansible handlers with notify, service restarts, execution order, listen, flush_handlers, loops, roles, changed_when pitfalls, and common troubleshooting mistakes.

Published

Updated

Read time 17 min read

Reviewed byDeepak Prasad

Ansible handlers to restart services only when playbook tasks change on Rocky Linux 10

Ansible docs define handlers as tasks that run only when notified by another task. By default, notified handlers run at flush points during a play (after pre_tasks, after roles/tasks, and after post_tasks), and each handler runs only once per batch even if multiple tasks notify it.

That pattern keeps playbooks idempotent: you deploy a config file with copy or template, notify restart httpd, and the service bounces only when the file actually changed. This guide walks through notify syntax, flush timing, listen, flush_handlers, loops, roles, failures, changed_when vs register, and troubleshooting when handlers do not run. It assumes you can run a basic playbook from your first playbook tutorial and know where handlers sit in playbook structure.

Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
Piece Role
notify on a task Queue a handler when the task reports changed
handlers list Play-level tasks that run only when notified
listen Map several handlers to one notification topic
meta: flush_handlers Run queued handlers immediately
changed_when Controls whether notify fires

What are Ansible Handlers?

Handlers are special tasks declared in the play-level handlers section. They do not run in task order during the play. They wait in a queue until a regular task notifies them and that task reports changed.

  • A handler is still a normal Ansible task under the hood—it uses modules such as service, systemd, or command.
  • Each handler should have a globally unique name within the play (and across static imports that share the handler namespace).
  • Notification uses the handler name string or a listen topic, not the module name.

Official handler guidance: Handlers — Running handlers in playbooks.


Why Use Handlers in Playbooks?

Without handlers, teams often drop systemctl restart into the main task list. That works once, but every rerun restarts the service even when nothing changed—noisy for operators and risky during business hours.

Handlers solve that coupling:

  • Fewer unnecessary restarts — services reload only after real config drift.
  • Cleaner idempotency — second playbook run shows ok on config tasks and skips handlers; pairs with idempotency practice.
  • One reaction per change batch — three config tasks can notify the same reload handler; Ansible still reloads once.

Use handlers for actions that should follow a change: restart, reload, cache clear, or “apply config now.” Keep desired state (package installed, file content) in regular tasks.


Handler vs Normal Task

Normal task Handler
When it runs In list order during the play At handler flush points, only if notified
Trigger Always (unless when skips it) Notifying task reported changed
Repeat on multiple notifies Runs every time it is reached Runs once per play batch per handler
Typical use Install package, template file Restart service, reload daemon

Normal tasks express desired state. Handlers express side effects that should happen because state changed.


Basic notify and handler Example

Create a small lab directory with inventory listing localhost ansible_connection=local and an ansible.cfg pointing at that inventory.

Save this playbook as basic-notify.yml:

yaml
---
- name: Basic notify and handler
  hosts: lab
  gather_facts: false
  tasks:
    - name: Deploy app config
      ansible.builtin.copy:
        content: "port=8080\n"
        dest: /tmp/handlers-demo-app.conf
        mode: "0644"
      notify: restart app service

  handlers:
    - name: restart app service
      ansible.builtin.debug:
        msg: "Handler ran — would restart service here"

Run it the first time:

bash
ansible-playbook basic-notify.yml

Sample output:

output
TASK [Deploy app config] *******************************************************
changed: [localhost]

RUNNING HANDLER [restart app service] ******************************************
ok: [localhost] => {
    "msg": "Handler ran — would restart service here"
}

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The config task reported changed, so Ansible queued and ran the handler at the next flush point.

Run the same playbook again:

bash
ansible-playbook basic-notify.yml

Sample output:

output
TASK [Deploy app config] *******************************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

No RUNNING HANDLER line appears—the file already matched, so notify did not fire.


Restart a Service Only When Config Changes

Swap the debug handler for a real service task. This example installs httpd, drops a config snippet, and restarts only when that file changes. For systemd units, timers, and cron jobs alongside service handlers, see manage services, cron, and systemd.

yaml
---
- name: Restart httpd only when config changes
  hosts: lab
  become: true
  gather_facts: false
  tasks:
    - name: Install httpd
      ansible.builtin.package:
        name: httpd
        state: present

    - name: Deploy httpd drop-in config
      ansible.builtin.copy:
        content: |
          ServerName localhost
        dest: /etc/httpd/conf.d/handlers-demo.conf
        mode: "0644"
      notify: restart httpd

  handlers:
    - name: restart httpd
      ansible.builtin.service:
        name: httpd
        state: restarted

First run installs the package, writes the file, and runs the handler:

bash
ansible-playbook service-restart.yml

Sample output:

output
TASK [Install httpd] ***********************************************************
changed: [localhost]

TASK [Deploy httpd drop-in config] *********************************************
changed: [localhost]

RUNNING HANDLER [restart httpd] ************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Second run with the same content skips the handler:

bash
ansible-playbook service-restart.yml

Sample output:

output
TASK [Install httpd] ***********************************************************
ok: [localhost]

TASK [Deploy httpd drop-in config] *********************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Prefer state: reloaded when the unit supports reload and a full restart is not required. Use ansible.builtin.systemd with daemon_reload: true when you change unit files themselves.

IMPORTANT
Before restarting nginx, sshd, named, or other critical daemons, validate the generated config first. Prefer the validate: option with modules such as template, copy, lineinfile, or blockinfile where it fits. For example, template can validate the temporary generated file before replacing the final destination—the validate command receives %s as the path to that temp file. Where validate: is not suitable, run a separate validation task before you notify or flush the restart handler. A bad config plus an automatic restart can turn a safe playbook into downtime.

When Do Handlers Run?

By default, Ansible runs notified handlers at handler flush points in a play:

  1. After pre_tasks finish
  2. After the main roles / tasks section finishes
  3. After post_tasks finish

Most simple playbooks only define tasks, so handlers appear to run at the end of the play. Plays that use pre_tasks or post_tasks can flush handlers before all work is done—advanced users often hit confusion here.

Handlers do not run immediately after the notifying task unless you call meta: flush_handlers. They also do not run when the notifying task ends with ok and no changed status.

Handlers queued by an earlier changed task do not run if a later task fails on that host before the next flush point—Ansible aborts remaining work and skips pending handlers unless you enable force_handlers at the play level, in ansible.cfg, or with --force-handlers on the CLI. Plan deploy plays so critical config is validated before tasks that might fail, or flush handlers with meta: flush_handlers when a later step depends on the restart having already happened.

Use meta: flush_handlers when a later task in the same play needs the handler result immediately—for example, a health check after a service reload.

Official reference: Controlling when handlers run.


Multiple Tasks and Multiple Handlers

When several tasks notify one handler, Ansible deduplicates the queue. The handler still runs once at the next flush point.

yaml
---
- name: Multiple tasks notify same handler
  hosts: lab
  gather_facts: false
  tasks:
    - name: Update config fragment A
      ansible.builtin.copy:
        content: "fragment=a\n"
        dest: /tmp/handlers-demo-frag-a.conf
        mode: "0644"
      notify: reload combined config

    - name: Update config fragment B
      ansible.builtin.copy:
        content: "fragment=b\n"
        dest: /tmp/handlers-demo-frag-b.conf
        mode: "0644"
      notify: reload combined config

  handlers:
    - name: reload combined config
      ansible.builtin.debug:
        msg: "Single reload after both fragments changed"
bash
ansible-playbook multi-notify.yml

Sample output:

output
TASK [Update config fragment A] ************************************************
changed: [localhost]

TASK [Update config fragment B] ************************************************
changed: [localhost]

RUNNING HANDLER [reload combined config] ***************************************
ok: [localhost] => {
    "msg": "Single reload after both fragments changed"
}

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Two changed tasks, one handler execution—that is the behavior you want for a shared service reload.

notify also accepts a YAML list when one change should trigger several handlers:

yaml
notify:
        - clear cache
        - restart app service

Example:

yaml
---
- name: Notify multiple handlers from one task
  hosts: lab
  gather_facts: false
  tasks:
    - name: Publish release marker
      ansible.builtin.copy:
        content: "release=v2\n"
        dest: /tmp/handlers-demo-release.txt
        mode: "0644"
      notify:
        - clear cache
        - restart app service

  handlers:
    - name: clear cache
      ansible.builtin.debug:
        msg: "Cache cleared"

    - name: restart app service
      ansible.builtin.debug:
        msg: "App service restarted"
bash
ansible-playbook multi-handlers.yml

Sample output:

output
RUNNING HANDLER [clear cache] **************************************************
ok: [localhost] => {
    "msg": "Cache cleared"
}

RUNNING HANDLER [restart app service] ******************************************
ok: [localhost] => {
    "msg": "App service restarted"
}

Each distinct handler still runs once per flush point; order follows handler definition order in the file, not the order listed in notify.


Handler Execution Order

Handlers run in the order they appear under handlers:, not the order tasks called notify.

In this play, the task notifies handler third before handler first, but Ansible runs handlers in definition order:

yaml
handlers:
    - name: handler first in file
      ansible.builtin.debug:
        msg: "Runs first (defined first)"

    - name: handler second in file
      ansible.builtin.debug:
        msg: "Runs second"

    - name: handler third in file
      ansible.builtin.debug:
        msg: "Runs third (notified first in notify list)"
bash
ansible-playbook order-demo.yml

Sample output:

output
RUNNING HANDLER [handler first in file] ****************************************
ok: [localhost] => {
    "msg": "Runs first (defined first)"
}

RUNNING HANDLER [handler third in file] ****************************************
ok: [localhost] => {
    "msg": "Runs third (notified first in notify list)"
}

Only notified handlers run. handler second never appeared because nothing notified it.


Use listen to Group Handlers

listen lets several handlers share one notification topic. Tasks notify: tls material changed without matching a handler name exactly—handlers subscribe via listen.

yaml
---
- name: listen groups handlers under one topic
  hosts: lab
  gather_facts: false
  tasks:
    - name: Deploy TLS bundle
      ansible.builtin.copy:
        content: "cert=updated\n"
        dest: /tmp/handlers-demo-tls.pem
        mode: "0644"
      notify: tls material changed

  handlers:
    - name: reload web front end
      ansible.builtin.debug:
        msg: "Reloading nginx"
      listen: tls material changed

    - name: restart app backend
      ansible.builtin.debug:
        msg: "Restarting app"
      listen: tls material changed
bash
ansible-playbook listen-demo.yml

Sample output:

output
RUNNING HANDLER [reload web front end] *****************************************
ok: [localhost] => {
    "msg": "Reloading nginx"
}

RUNNING HANDLER [restart app backend] ******************************************
ok: [localhost] => {
    "msg": "Restarting app"
}

listen topics are not templated—use a fixed string. Handler name values remain unique for logging and --list-handlers output.


Run Handlers Early with flush_handlers

Default timing waits until the play's task sections finish. Use ansible.builtin.meta: flush_handlers when a later task depends on the handler having already run—common before an HTTP check or integration test.

yaml
---
- name: flush_handlers before later tasks
  hosts: lab
  gather_facts: false
  tasks:
    - name: Stage config that needs immediate reload
      ansible.builtin.copy:
        content: "stage=1\n"
        dest: /tmp/handlers-demo-stage.conf
        mode: "0644"
      notify: apply staged config

    - name: Flush handlers now
      ansible.builtin.meta: flush_handlers

    - name: Run check that depends on reloaded config
      ansible.builtin.debug:
        msg: "Continuing after handler flush"

  handlers:
    - name: apply staged config
      ansible.builtin.debug:
        msg: "Config applied before dependent task"
bash
ansible-playbook flush-demo.yml

Sample output:

output
TASK [Stage config that needs immediate reload] ********************************
changed: [localhost]

TASK [Flush handlers now] ******************************************************

RUNNING HANDLER [apply staged config] ******************************************
ok: [localhost] => {
    "msg": "Config applied before dependent task"
}

TASK [Run check that depends on reloaded config] *******************************
ok: [localhost] => {
    "msg": "Continuing after handler flush"
}

You can call flush_handlers more than once in a play; each flush runs all handlers notified since the previous flush—not one selected handler.

WARNING
meta: flush_handlers does not target a single handler. It executes every handler currently queued on that host. If you only wanted one restart, review which tasks notified handlers before the flush line.

A handler normally runs once per flush point. After handlers run—automatically at a flush point or manually via meta: flush_handlers—later tasks can notify the same handler again, and it can run at the next flush point in the same play. Official docs describe this as handlers being eligible for notification again after they have executed.


Handlers with Loops

If a looped task changes on any iteration, Ansible treats the whole task as changed. All handlers notified by that task are queued—not one notification per loop item.

yaml
---
- name: Looped task notifies handler
  hosts: lab
  gather_facts: false
  tasks:
    - name: Ensure marker files exist
      ansible.builtin.copy:
        content: "item={{ item }}\n"
        dest: "/tmp/handlers-demo-{{ item }}.marker"
        mode: "0644"
      loop:
        - alpha
        - beta
      notify: rebuild index

  handlers:
    - name: rebuild index
      ansible.builtin.debug:
        msg: "Handler runs once after loop changes"
bash
ansible-playbook loop-notify.yml

Sample output:

output
TASK [Ensure marker files exist] ***********************************************
changed: [localhost] => (item=alpha)
changed: [localhost] => (item=beta)

RUNNING HANDLER [rebuild index] ************************************************
ok: [localhost] => {
    "msg": "Handler runs once after loop changes"
}

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Official docs describe this as notifying based on the task's overall changed result, not per-item handler fan-out. If one item in the loop should not trigger a global restart, split the work into separate tasks or tighten changed_when on the loop.

Do not put notify on the handler itself—notify belongs on the task that performs the change.


Handlers in Roles

Roles ship handler tasks under roles/<role_name>/handlers/main.yml. When the role is applied, those handlers merge into the play's handler list.

Global handler namespace

Handler names share one global namespace for the play: play-level handlers, handlers from every role, and handlers from static imports all compete for the same name space. Ansible docs warn that duplicate handler names are problematic—the last loaded definition wins and can shadow an earlier handler you thought you were notifying.

Typical pattern:

  • Role task templates templates/app.conf.j2 and notifies restart app.
  • Handler definition lives in the role's handlers/main.yml.
  • Playbook lists roles:—no duplicate handler block required in the play.

Name conflicts across roles

Two roles both defining restart service is a common production bug. The second role loaded overwrites the first. Prefer unique names (restart nginx, restart app backend) or listen topics scoped to the event.

When you must target a handler inside a specific role, use the role prefix in notify:

yaml
notify: my_role : restart app

That role_name : handler_name form disambiguates role handlers when multiple roles define similar names. See Handlers in roles in the official docs.

For role layout and task order, see playbook structure.


Handlers, Failures, and force_handlers

When a task fails on a host, Ansible aborts remaining tasks on that host and skips notified handlers by default—even if an earlier task already queued them.

bash
ansible-playbook fail-demo.yml

Sample output:

output
TASK [Notify then fail] ********************************************************
changed: [localhost]

TASK [Intentional failure] *****************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

No RUNNING HANDLER line—the play failed first.

To run handlers despite failure, enable force_handlers in any of these places:

CLI (one run):

bash
ansible-playbook fail-demo.yml --force-handlers

Play level (one play in the playbook):

yaml
- name: Deploy with forced handlers on failure
  hosts: lab
  force_handlers: true
  tasks:
    # ...

ansible.cfg (all plays on this control node):

ini
[defaults]
force_handlers = True

With forcing enabled:

Sample output:

output
TASK [Intentional failure] *****************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}

RUNNING HANDLER [cleanup handler] **********************************************
ok: [localhost] => {
    "msg": "Handler skipped when play fails"
}

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Use force_handlers sparingly—running restarts after a half-applied config can make debugging harder. Fix the failing task first in production playbooks.


Handlers vs changed_when, register, and when

changed_when gates notification

Handlers react to the task's final changed result. If you override it with changed_when, you control notification.

Task with changed_when: false never notifies:

yaml
- name: Probe without real change
      ansible.builtin.command: echo "no change"
      changed_when: false
      notify: should not run

Task with changed_when: true always notifies:

yaml
- name: Force changed status
      ansible.builtin.command: echo "mark changed"
      changed_when: true
      notify: should run once
bash
ansible-playbook changed-when-demo.yml

Sample output:

output
TASK [Probe without real change] ***********************************************
ok: [localhost]

TASK [Force changed status] ****************************************************
changed: [localhost]

RUNNING HANDLER [should run once] **********************************************
ok: [localhost] => {
    "msg": "Handler notified because changed_when was true"
}

Wrong changed_when logic causes handlers to fire every run—or never fire when config actually drifted. Expression rules match when conditionals and operators.

notify vs register vs when

Forum threads often compare notify with register + when: result.changed. They solve different timing problems:

Approach Use when
notify + handler The action is a deferred side effect—restart, reload, cache clear—usually after a batch of config tasks
register + when on the next task The next task needs the prior result immediately—stdout, rc, or JSON from the module
changed_when A command does not report changed accurately but should (or should not) queue handlers

Example: template a config (notify: restart app) because the restart can wait until flush time. Run register: config_test and when: config_test.rc != 0 on a validation task that must run before you allow a restart—that validation belongs in the main task list, not in a handler you have not flushed yet.

Do not replace handlers with register + service: restarted in the next task unless you accept a restart on every play— that breaks idempotency goals.


Why is my Ansible Handler Not Running?

Symptom Check
No RUNNING HANDLER in output Did the notifying task report changed (not just ok)?
Handler name exists but never called Does notify exactly match handler name or listen topic (spacing, case)?
Handler skipped after changes Did a later task fail on that host? Enable force_handlers only if you intend that.
Role handler never fires Is a duplicate handler name in another role or the play shadowing it (last wins)?
Expected restart mid-play Do you need meta: flush_handlers before the dependent task?
Handler when never true Is when a real boolean (enable_reload | bool)—not a string from -e like "false"?
Handler runs every play Is command/shell always changed, or is changed_when: true stuck on?
Partial --tags run Was the notifying task skipped by tags? Handlers only queue from tasks that actually ran.
handlers block ignored Is handlers at play level, sibling to tasks—not nested under tasks?

Start with ansible-playbook -v or -vv and confirm whether the config task shows changed. That single line explains most "handler did not run" tickets.


Handler Best Practices

  • Name handlers for the action: restart httpd, reload nginx, not handler1.
  • Notify from the task that changes state—usually template, copy, or lineinfile.
  • Prefer reload over restart when the daemon supports it; use validate: on file modules where it fits before notifying restarts.
  • Group related reactions with listen when one event should touch multiple services.
  • Keep handler tasks short—one module call, no long shell scripts.
  • Use unique handler names across roles; remember role_name : handler_name when disambiguating.
  • Test idempotency: run the play twice; second run should skip handlers when config is stable (idempotency guide).
  • Document why flush_handlers is needed when you break default timing.
  • Use state-aware modules in tasks, FQCN modules (ansible.builtin.copy) in new playbooks, and notify on config changes—not bare package install unless install truly requires an immediate restart.

Handlers with Tags, Check Mode, and Rolling Updates

Tags: Tags select which tasks run. A handler runs only if a selected task notifies it and reports changed. Do not rely on tagging the handler alone as your main control mechanism—tag the notifying tasks instead. Partial --tags runs are a common reason handlers never queue.

Check mode: In check mode (--check), notifying tasks may still report changed without applying changes on the host. Handlers can still be notified from those tasks—test handler side effects carefully before trusting check-mode output for restart plays.

Rolling updates (serial): With serial, Ansible runs the play in batches; each batch behaves like a smaller play run. Handler flush timing and service restarts happen per batch—test restart behavior when deploying to many hosts, not only on a single lab host.


Summary

Handlers are tasks that run only when notified by a changed task, at flush points after pre_tasks, roles/tasks, and post_tasks unless you call meta: flush_handlers. They keep restarts tied to real configuration changes, deduplicate multiple notifications, and run in handler definition order within the global namespace shared by plays and roles. Use listen to group reactions, validate configs before critical restarts, and treat notify vs register as deferred side effects vs immediate follow-up tasks. On failure, handlers are skipped unless force_handlers is set—fix the root error first in production.


References

  • Handlers — Running handlers in playbooks — notify, listen, flush, and role handlers
  • Controlling when handlers run — flush points and force_handlers
  • Handlers in roles — global namespace and role_name : handler_name
  • Tags — how tagged runs affect which tasks notify handlers
  • Ansible playbook structure — where handlers sit in a play
  • Ansible idempotency — ok vs changed and safe reruns
  • Ansible when conditionals — changed_when, failed_when, and handler when

Frequently Asked Questions

1. When do Ansible handlers run?

By default at handler flush points in a play: after pre_tasks, after the main roles/tasks section, and after post_tasks. Simple playbooks with only tasks look like end-of-play timing. Use meta: flush_handlers when a later task needs the handler result immediately.

2. Do handlers run if I notify them twice?

They are queued once per play batch. Multiple notify calls to the same handler still produce a single handler run at the next flush point.

3. Can a handler run before later tasks in the same play?

Yes. meta: flush_handlers runs all pending notified handlers immediately, then the play continues with remaining tasks.

4. What happens to handlers when a task fails?

On a failed host, notified handlers are skipped unless you set force_handlers at the play, in ansible.cfg, or pass --force-handlers on the CLI.

5. Should I put variables in handler names?

Avoid templated handler names. Use a static name or listen topic, and pass details through variables inside the handler task body.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …