Chapter 7: Handlers
Learning Objectives
- Understand what handlers are and why they exist as a separate mechanism from tasks
- Write handlers using the
handlersblock and trigger them withnotify - Understand when and how handlers run (only when a task reports changes)
- Use
meta: flush_handlersto force immediate handler execution - Notify multiple handlers and use handler listening for grouped notifications
- Apply best practices for handler naming and organization
Explanation
When you configure a service, you often need to restart it after changing its configuration. But restarting is a separate action from modifying the configuration — it should only happen if the configuration actually changed. This is exactly the problem handlers solve.
A handler is a task that only runs when explicitly triggered by another task. Think of it as an "interrupt" or "callback" that fires after a change is detected.
Handlers exist because of idempotency. If a task checks whether nginx is already configured correctly and it is, Ansible reports "ok" (no change). If we also unconditionally restarted nginx every time the playbook ran, we would be restarting a service even when nothing changed — that's not idempotent. Handlers ensure that side effects (like restarts) only happen when actual changes occurred.
Basic Handler Syntax
A handler looks like a task but lives in the handlers: block of a play:
---
- name: Configure nginx
hosts: webservers
tasks:
- name: Copy nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
- name: Ensure nginx is running
service:
name: nginx
state: started
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
The notify directive on the template task references the handler by name. When the template task runs and detects a change (the file on the remote host differs from the source), it fires the Restart nginx handler.
Pro Tip: Handler names must be unique within a play. If two handlers share the same name, only the first one will be called. Use descriptive, unique names like
Restart nginx on port changerather than justrestart.
Handler Execution Order
Handlers run at the end of the play, after all tasks in all plays are complete. The order is determined by:
- Notification order: Handlers run in the order they were first notified.
- Handler block order: Within a single handler block, they run top to bottom.
Consider this example with two handlers:
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify:
- Restart nginx
- Reload fail2ban
- name: Update SSL certificate
copy:
src: cert.pem
dest: /etc/ssl/certs/cert.pem
notify: Restart nginx
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
- name: Reload fail2ban
service:
name: fail2ban
state: reloaded
Even though Restart nginx is notified twice, it only runs once (duplicate notifications are deduplicated). The order of execution will be: Restart nginx (first notified) → Reload fail2ban. If Update SSL certificate notified first, then Reload fail2ban → Restart nginx.
Flushing Handlers with meta: flush_handlers
By default, handlers run at the end of each play. But sometimes you need a handler to run immediately — for example, to reload a configuration and then use the new configuration in a subsequent task.
You can force immediate handler execution with meta: flush_handlers:
---
- name: Configure and test database
hosts: databases
tasks:
- name: Update PostgreSQL configuration
template:
src: postgresql.conf.j2
dest: /etc/postgresql/14/main/postgresql.conf
notify: Restart PostgreSQL
- name: Flush handlers to restart PostgreSQL now
meta: flush_handlers
- name: Verify database is accessible
postgresql_query:
db: myapp
query: SELECT version();
- name: Create application database
postgresql_db:
name: myapp
state: present
become: true
become_user: postgres
handlers:
- name: Restart PostgreSQL
service:
name: postgresql
state: restarted
In this example, Restart PostgreSQL runs right after meta: flush_handlers, before the Verify database is accessible task. This allows you to test the effects of the restart within the same play.
Pro Tip: Use
meta: flush_handlerssparingly. It breaks the normal flow where handlers run once at the end, which can surprise teammates. It is most useful for testing configurations or when subsequent tasks genuinely depend on the handler having run.
Handler Listening
Sometimes you want multiple tasks to be able to trigger the same handler without hard-coding the handler name in each task. Ansible's listener pattern lets handlers "listen" for notifications by a logical topic rather than a specific name.
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: "nginx service"
- name: Update SSL certificate
copy:
src: cert.pem
dest: /etc/ssl/certs/cert.pem
notify: "nginx service"
- name: Update nginx ports
lineinfile:
path: /etc/nginx/nginx.conf
regexp: "^listen"
line: "listen {{ http_port }};"
notify: "nginx service"
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
listen: "nginx service"
- name: Reload fail2ban
service:
name: fail2ban
state: reloaded
listen: "nginx service"
Here, three different tasks all notify "nginx service". Both handlers Restart nginx and Reload fail2ban listen to "nginx service" and both will be triggered. This decouples the notifier from the specific handler names, making it easier to add new handlers that react to the same event.
Handler Naming Best Practices
Handlers follow the same naming rules as tasks (they are tasks technically). Best practices:
- Be specific:
Restart nginx because config changedis clearer thanrestart. - Be unique: No two handlers in the same play should share a name.
- Prefix by action: Start with the action —
Reload,Restart,Restart if needed. - Indicate the service:
Reload nginxnotreload.
When Handlers Do Not Run
Handlers will NOT run in these situations:
- The triggering task reports
changed=false(no change was made) - The play fails before the triggering task completes
- A task with
changed_when: falsealways reports ok, never changed, so it never triggers handlers
If you want a handler to always run (even if the preceding tasks didn't detect a change), you can force it with meta: flush_handlers in a separate task, but note this task will always report ok — it does not track whether any handlers actually ran.
Examples
Example 1: Multi-Service Handler Chain
---
- name: Configure web application stack
hosts: webservers
vars:
app_user: www-data
tasks:
- name: Update nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify:
- Restart nginx
- Reload fail2ban
- name: Configure app settings
template:
src: app.conf.j2
dest: /etc/myapp/app.conf
notify: Restart myapp
- name: Ensure nginx is enabled
service:
name: nginx
enabled: yes
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
- name: Reload fail2ban
service:
name: fail2ban
state: reloaded
- name: Restart myapp
service:
name: myapp
state: restarted
Example 2: Conditional Handler Notification
You cannot use a when clause on a notify directly. Instead, use a conditional inside a task that registers its result and then notifies:
tasks:
- name: Update configuration
template:
src: "{{ item }}.conf.j2"
dest: "/etc/{{ item }}/{{ item }}.conf"
loop:
- nginx
- myapp
register: config_updates
- name: Flush handlers if any config changed
meta: flush_handlers
when: config_updates is changed
handlers:
- name: Restart services
service:
name: "{{ item }}"
state: restarted
loop:
- nginx
- myapp
Example 3: Handler for Multiple Notify Names
---
- name: Infrastructure configuration
hosts: all
tasks:
- name: Update sysctl settings
sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
state: present
reload: yes
sysctl_file: /etc/sysctl.d/99-custom.conf
loop:
- { name: 'net.ipv4.ip_forward', value: '1' }
- { name: 'net.core.somaxconn', value: '4096' }
notify: Apply sysctl changes
handlers:
- name: Apply sysctl changes
command: sysctl --system
listen: "Apply sysctl changes"
Hands-On Exercises
Exercise 1: First Handler
Goal: Create a playbook with a handler that only restarts a service when the configuration actually changes.
- Create a directory structure:
files/nginx.confand aplaybook.yml. - Write an nginx config file with a specific
server_namevalue. - Write a playbook that:
- Copies the nginx config to
/tmp/nginx.confon localhost - Has a handler named
Restart nginx mockthat usesdebugto print "Restarting nginx..." - Uses
notify: Restart nginx mock - Run the playbook once — observe the handler fires.
- Run the playbook again without changes — observe the handler does NOT fire.
- Change the
server_namein the config file, run again — observe the handler fires again.
Expected outcome: First run shows changed: true and "Restarting nginx...". Second run shows ok: true (no change) and NO handler message. After editing the config, third run shows changed: true and handler fires again.
Hint: Handlers only run when a task's changed status is true. If the file content is identical, copy reports ok, not changed.
Exercise 2: Handler Flush
Goal: Use meta: flush_handlers to run a handler immediately, then use its result.
- Create a playbook that targets
localhost. - Create a task that writes "version=1" to
/tmp/app.conf. - Add a handler that reads
/tmp/app.confand sets a factapp_versionfrom it. - Use
meta: flush_handlersafter the write task. - Add a subsequent task that asserts
app_version == "1"usingassert.
Expected outcome: The handler runs after the flush_handlers directive, reads the version, and the assertion passes.
Hint: This pattern is useful when subsequent tasks need to react to the state that the handler creates. Note that the handler's output must be registered for the subsequent task to use it.
Exercise 3: Multiple Handlers with Listening
Goal: Use the listener pattern to trigger multiple handlers with a single notification.
- Create a playbook with 3 tasks, each modifying a different file.
- Each task notifies the same topic:
"infrastructure reload". - Create 2 handlers, both listening to
"infrastructure reload": handler1: debug prints "Handler 1: reloading config"handler2: debug prints "Handler 2: restarting daemon"- Run the playbook and verify both handlers fire.
Expected outcome: Even though only one task explicitly triggered "infrastructure reload", both listeners fire because they both subscribed to that topic.
Hint: listen is a top-level key in the handler definition, alongside name, service, command, etc. It does not replace the handler name — it adds a topic subscription.
Exercise 4: Handler Dependencies
Goal: Create a handler that depends on another handler running first.
- Write a playbook with these tasks:
- Task A: modifies
/tmp/base.conf, notifiesInitialize base config - Task B: uses
meta: flush_handlersto runInitialize base configimmediately - Task C: modifies
/tmp/app.conf, notifiesStart application - Create two handlers:
Initialize base config: usescopyto ensure a base config exists, thenset_factwithbase_ready: trueStart application: has adebugthat prints "Application started" but only ifbase_readyis defined (usewhen: base_ready is defined)- Run the playbook twice — first with a fresh
/tmp/, then after files exist.
Expected outcome: On first run, handler order matters — if Start application runs before Initialize base config, the conditional check fails. This demonstrates why handler order is important.
Hint: Handler execution order follows notification order, not handler block order. Use multiple notify directives or meta: flush_handlers strategically to control ordering.
Exercise 5: Handler Debugging
Goal: Learn to troubleshoot handlers that are not firing as expected.
- Create a playbook where a task's
changed_whenis set toFalse, and the task has anotify. - Run the playbook and observe: the task reports
oknotchanged, and the handler never fires. - Fix it by removing
changed_when: falseor setting it to a condition that evaluates totrue. - Verify the handler now fires.
Expected outcome: With changed_when: false, no handler fires. Removing/changing it restores the handler behavior.
Hint: changed_when overrides Ansible's automatic change detection. If you set it to false, Ansible will never report the task as changed, and no handlers will be notified. This is sometimes used deliberately (e.g., for read-only debug tasks), but it has the side effect of suppressing handler notifications.
Beginner Notes
Don't worry if handlers feel strange at first. They are just "tasks that wait."
Think of it this way: - A regular task runs immediately every time the playbook runs. - A handler is like a post-it note you leave for yourself: "If the config file changed, remember to restart the service at the end."
Handlers exist because of idempotency. If your config is already correct, you don't want to restart the service for no reason.
Troubleshooting: Common Beginner Errors
Error 1: "Handler never fires"
Why it happens: Your task reports ok (no change), so Ansible sees no reason to run the handler.
How to recognize it: The handler's debug message never appears, and PLAY RECAP shows changed=0.
How to fix it:
- Check that your file/template actually changed. If the source and destination are identical, Ansible reports ok.
- Remove changed_when: false from the task if you accidentally added it.
- Force the handler with meta: flush_handlers if you need it to run immediately.
Error 2: "Handler fires every single time"
Why it happens: Your task always reports changed, even when nothing actually changed. Ansible thinks something happened, so it notifies the handler.
How to recognize it: Every playbook run shows changed=1 and the handler runs.
How to fix it:
- Use changed_when to tell Ansible when the task ACTUALLY changed:
template, copy, or lineinfile instead of command or shell.
Error 3: "Handler runs in the wrong order"
Why it happens: Handlers run in the order they were first notified, not in the order they appear in the handlers: block.
How to recognize it: Handler B runs before Handler A, even though A appears first in the file.
How to fix it:
- Make sure Task A (which notifies Handler A) runs before Task B (which notifies Handler B).
- Use meta: flush_handlers between task groups to control exactly when handlers fire.
Module Review — Test Yourself
Q1: Why do handlers exist? Why not just put the restart inside a regular task?
Click to reveal the answer.
Answer
Handlers prevent unnecessary restarts. If the config file didn't change, you don't want to restart the service. A regular task would restart every time. A handler only restarts when the task reports changed.
Q2: When do handlers run by default?
Click to reveal the answer.
Answer
At the end of each play, after all tasks are complete. They run in the order they were first notified.
Q3: What does meta: flush_handlers do?
Click to reveal the answer.
Answer
It forces all pending handlers to run immediately, before the next regular task. Use it when a later task depends on the handler having already run.
Q4: If a task has changed_when: false and it has a notify, will the handler fire?
Click to reveal the answer.
Answer
No. changed_when: false forces the task to always report ok. Since Ansible sees no change, it does not notify the handler.
Q5: What is the difference between notify: Restart nginx and listen: nginx reload?
Click to reveal the answer.
Answer
notify: Restart nginxcalls a handler by its exact name.listen: nginx reloadlets a handler subscribe to a topic. Any task that notifies"nginx reload"will trigger ALL handlers that listen to that topic.
Mini Practice
Create a playbook that:
- Creates
/tmp/myapp/directory. - Writes
version=1.0to/tmp/myapp/config.txt. - Has a handler that appends
restartedto/tmp/myapp/status.txt. - Run it twice.
What should you see?
- First run: The file is created → handler fires → status.txt contains restarted.
- Second run: File already exists → ok → handler does not fire → status.txt still only contains one restarted.
If your handler fires on the second run, your task is not idempotent. Check what module you used and whether it is comparing the content correctly.
Summary
- Handlers are tasks that only run when explicitly notified by another task that made changes.
- Use
notify: handler_nameto trigger a handler from a task. - Handlers run at the end of each play in the order they were first notified (duplicates are removed).
meta: flush_handlersforces all pending handlers to run immediately.- The
listendirective lets multiple handlers subscribe to the same logical topic. - Handlers do not run if the triggering task did not report a change.
- Handler names must be unique within a play.
- Use
changed_whencarefully — setting it tofalsesuppresses handler notifications.
Additional Resources
- Ansible Documentation: Handlers — Official documentation on handlers and notifiers.
- Ansible Handlers: Complete Guide (DigitalOcean) — A practical tutorial with real-world examples.
- Ansible Best Practices: Handler Organization (Red Hat) — Guidance on structuring handlers in large playbooks.