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.
~/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, orcommand. - Each handler should have a globally unique
namewithin the play (and across static imports that share the handler namespace). - Notification uses the handler
namestring or alistentopic, 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
okon 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:
---
- 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:
ansible-playbook basic-notify.ymlSample 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=0The config task reported changed, so Ansible queued and ran the handler at the next flush point.
Run the same playbook again:
ansible-playbook basic-notify.ymlSample output:
TASK [Deploy app config] *******************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0No 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.
---
- 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: restartedFirst run installs the package, writes the file, and runs the handler:
ansible-playbook service-restart.ymlSample 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=0Second run with the same content skips the handler:
ansible-playbook service-restart.ymlSample 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=0Prefer 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.
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:
- After
pre_tasksfinish - After the main
roles/taskssection finishes - After
post_tasksfinish
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.
---
- 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"ansible-playbook multi-notify.ymlSample 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=0Two 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:
notify:
- clear cache
- restart app serviceExample:
---
- 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"ansible-playbook multi-handlers.ymlSample 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:
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)"ansible-playbook order-demo.ymlSample 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.
---
- 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 changedansible-playbook listen-demo.ymlSample 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.
---
- 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"ansible-playbook flush-demo.ymlSample 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.
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.
---
- 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"ansible-playbook loop-notify.ymlSample 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=0Official 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.j2and notifiesrestart 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:
notify: my_role : restart appThat 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.
ansible-playbook fail-demo.ymlSample 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=0No RUNNING HANDLER line—the play failed first.
To run handlers despite failure, enable force_handlers in any of these places:
CLI (one run):
ansible-playbook fail-demo.yml --force-handlersPlay level (one play in the playbook):
- name: Deploy with forced handlers on failure
hosts: lab
force_handlers: true
tasks:
# ...ansible.cfg (all plays on this control node):
[defaults]
force_handlers = TrueWith forcing enabled:
Sample 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=0Use 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:
- name: Probe without real change
ansible.builtin.command: echo "no change"
changed_when: false
notify: should not runTask with changed_when: true always notifies:
- name: Force changed status
ansible.builtin.command: echo "mark changed"
changed_when: true
notify: should run onceansible-playbook changed-when-demo.ymlSample 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, nothandler1. - Notify from the task that changes state—usually
template,copy, orlineinfile. - Prefer reload over restart when the daemon supports it; use
validate:on file modules where it fits before notifying restarts. - Group related reactions with
listenwhen 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_namewhen disambiguating. - Test idempotency: run the play twice; second run should skip handlers when config is stable (idempotency guide).
- Document why
flush_handlersis needed when you break default timing. - Use state-aware modules in tasks, FQCN modules (
ansible.builtin.copy) in new playbooks, andnotifyon 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
handlerssit in a play - Ansible idempotency —
okvschangedand safe reruns - Ansible when conditionals —
changed_when,failed_when, and handlerwhen

