Notify & Listen #

Successful system automation relies heavily on your ability to isolate every piece of code so it stays modular and maintainable. When a playbook grows from a few task lines into dozens of interconnected roles, dependencies between components often get complicated. If you use the conventional method where one main task must know the exact name of a recovery task in another section, you’re creating a very tight coupling level.

To solve this problem, Ansible provides two very powerful event triggering mechanisms: notify and listen. Their combination lets you implement the Publish-Subscribe (Pub/Sub) architecture pattern that loosely separates notification senders from action receivers (loose coupling). This article discusses in depth how to design clean event reaction chains, understand the internal execution ordering algorithm, and debug strategies when handlers don’t trigger as they should.


Architecture Patterns: Notify by Name vs Listen (Pub/Sub) #

Historically, Ansible only supported triggering handlers by their handler name (known as Notify by Name). However, since version 2.2, Ansible introduced the listen keyword that defines a topic or event. The philosophical difference between the two approaches greatly determines the quality of your playbook structure.

1. Notify by Name (Tightly-Coupled) #

In this pattern, the main task points directly to the specific name of the handler to run.

  • Advantages: Very easy to understand in small and mid-sized projects. The relationship between trigger and executor is explicit.
  • Disadvantages: The main task must know the exact name of the executing handler. If you change a handler name for grammar fixes or refactoring, you have to find and change every notify line in all task files that reference it. This pattern makes it hard to create independent, reusable roles.

2. Listen Topic (Loosely-Coupled Publish-Subscribe) #

In this pattern, the main task only “publishes” a message or event name to Ansible’s internal broker. On the other side, one or more handlers register themselves to “listen” for that event using the listen keyword.

  • Advantages: The main task doesn’t need to know which handlers are listening for the event, or even whether any handler responds to it. This separation of responsibilities makes role writing very clean. Several handlers in separate roles can listen to the same event without modifying the calling code.
  • Disadvantages: Tracing the relationship between tasks and handlers becomes implicit, so it requires disciplined event topic name documentation.

The comparison table below illustrates the technical differences between the two patterns:

CriteriaNotify by NameListen Topic (Pub/Sub)
Trigger (notify)Specific handler name.Free event/topic name.
Receiver (listen)Doesn’t use the listen keyword.Uses the listen: <event_name> keyword.
Relationship1-to-1 (one notification to one handler).1-to-Many (one notification can trigger many handlers at once).
Dependency LevelHigh (Tightly-Coupled).Low (Loosely-Coupled).
Role ScalabilityHard to integrate between roles from different teams.Very easy to integrate across roles through event standardization.

Here’s a visualization of the Publish-Subscribe architecture model in Ansible using a Mermaid diagram:

flowchart TD
    subgraph PublisherTasks["Sender Side (Tasks)"]
        T1["Task: Update nginx.conf"] -->|"notify: 'webserver config changed'"| EventBroker(("Event Broker (Ansible Engine)"))
        T2["Task: Update php.ini"] -->|"notify: 'webserver config changed'"| EventBroker
    end

    subgraph EventBrokerSub["Event Distribution Phase"]
        EventBroker -->|"Distribute Event to all Subscribers"| SubQueue{{"Topic: 'webserver config changed'"}}
    end

    subgraph SubscribersHandlers["Receiver Side (Handlers)"]
        SubQueue -->|"Listen: 'webserver config changed'"| H1["Handler: Restart Nginx"]
        SubQueue -->|"Listen: 'webserver config changed'"| H2["Handler: Reload PHP-FPM"]
        SubQueue -->|"Listen: 'webserver config changed'"| H3["Handler: Clear OpCache"]
    end

Real Scenario Implementation: Using Listen Across Roles #

Let’s look at a real scenario where the Publish-Subscribe architecture using listen proves far superior to Notify by Name.

Imagine you have a playbook managing a dynamic web server stack involving three different roles:

  1. common: Manages global security configuration and network parameters.
  2. nginx: Manages the front proxy server.
  3. php_fpm: Manages the PHP backend processor.

When there’s a change to the system security configuration in the common role (for example, adjusting a new root SSL certificate), you want both services (nginx and php_fpm) to reload so they can load the new certificate. Without listen, the common role must know the internal handler names of the nginx and php_fpm roles, which breaks role isolation boundaries.

Here’s how you solve this problem elegantly with listen:

# ==============================================================================
# FILE: roles/common/tasks/main.yml
# ==============================================================================
---
- name: Update the system Root SSL certificate
  copy:
    src: corporate-ca.crt
    dest: /usr/local/share/ca-certificates/corporate-ca.crt
  notify: system certificates updated
  # ✓ The common role only fires the "system certificates updated" event.
  # This role doesn't care who will respond to the event.
# ==============================================================================
# FILE: roles/nginx/handlers/main.yml
# ==============================================================================
---
- name: Reload the Nginx Service
  systemd:
    name: nginx
    state: reloaded
  listen: system certificates updated
  # ✓ The handler inside the nginx role listens for the event and responds independently.
# ==============================================================================
# FILE: roles/php_fpm/handlers/main.yml
# ==============================================================================
---
- name: Reload the PHP-FPM Service
  systemd:
    name: php-fpm
    state: reloaded
  listen: system certificates updated
  # ✓ PHP-FPM also listens for the same event and performs a reload.

With the pattern above, if you add a new role in the future, say apache or varnish, you just add a handler listening for the system certificates updated event inside that new role. You don’t need to touch or modify the task files in the common role at all.


Handler Execution Ordering Algorithm #

One very important technical detail every Ansible developer must understand is: the calling order of notify in the tasks section has absolutely no effect on handler execution order.

Ansible has an internal algorithm that evaluates and runs handlers based on the definition order of handlers in the configuration file, not the chronological order of notification triggers.

Let’s study the case example below to see how this algorithm works:

# Playbook to manipulate a web application
- name: Web Application Deployment
  hosts: webservers
  tasks:
    - name: Copy the latest program code
      git:
        repo: https://github.com/example/app.git
        dest: /var/www/html
      notify:
        - Run Database Migration
        - Start the Application Service
        - Stop the Application Service
      # We trigger notifications in the order: Migration -> Start -> Stop.

  handlers:
    # THE HANDLER DEFINITIONS BELOW ARE WRITTEN IN A DIFFERENT LOGICAL ORDER:
    - name: Stop the Application Service
      systemd:
        name: webapp
        state: stopped

    - name: Run Database Migration
      command: /var/www/html/bin/migrate.sh

    - name: Start the Application Service
      systemd:
        name: webapp
        state: started

Execution Flow Analysis #

Even though in the tasks section we call Run Database Migration before Stop the Application Service, Ansible executes the queue in the following order:

  1. Stop the Application Service (Defined first in the handlers: block)
  2. Run Database Migration (Defined second in the handlers: block)
  3. Start the Application Service (Defined third in the handlers: block)

This order is very logical for application maintenance (stopping the application before running the database migration to prevent data corruption, then starting it again). However, if you write the definition order wrong in the handlers: section, say placing Start the Application Service above Stop the Application Service, Ansible starts the application first then stops it, leaving your application permanently dead after the playbook finishes.

[!IMPORTANT] Always arrange the handler list in the handlers/main.yml file or handlers: block according to the correct chronological dependency order (for example, stop service -> database migration -> start service -> clear cache). Don’t rely on the notify calling order in tasks to control handler execution logic flow.


4 Main Causes of Handler Trigger Failures #

Often when writing automation, you find situations where a handler you expected to run is ignored by Ansible. Here’s an in-depth analysis of the four main causes of that problem along with their handling solutions.

1. Character and Case Differences (Typos & Case-Sensitivity) #

Ansible matches notification strings with handler names literally and case-sensitively.

  • Problem:
    notify: Restart Postgresql  # Uses a lowercase 'l'
    
    While the handler is defined as:
    - name: Restart PostgreSQL  # Uses an uppercase 'L'
    
  • Solution: Make sure the names in both places are exactly the same. Use the Search and Replace feature in your code editor to thoroughly verify string matches.

2. The Task Doesn’t Produce a changed Status (Idempotency Satisfied) #

Handlers are specifically designed to minimize unnecessary actions. If the task with a notify line makes no changes to the managed node (returns ok status), the notification isn’t sent to the event broker.

  • Problem: You run the playbook for the second time. The configuration file was already copied with identical content. The task status is ok. You wonder why the nginx service didn’t restart.
  • Solution: This is correct and expected behavior. However, if you really want to force that task to always report a changed status (for example for debugging purposes or dynamic configuration reader tasks), you can add the changed_when: true parameter to that task.

3. The Playbook Stops Before the End of the Play (Play Failed) #

If a main task fails mid-way, Ansible by default stops playbook execution for that host to prevent further system damage. As a result, the deferred handler execution phase at the end of the play is never reached.

  • Problem: The apache configuration was successfully changed (changed status). The next task (installing the php module) fails due to an internet connection problem. The playbook stops. The new apache configuration isn’t active in memory because the restart handler never ran.
  • Solution: Use the force_handlers: true option on your playbook declaration to force Ansible to still run already-notified handlers even if a subsequent main task fails.

4. Role Scope Isolation #

When you include a role using a dynamic module like include_role mid-play, the handlers defined inside that role may not be registered in Ansible’s memory when a main task outside the role calls them.

  • Problem: A task at the main playbook level tries to send a notification to a handler inside roles/db/handlers/main.yml when the db role is included using the include_role command.
  • Solution: Use import_role instead of include_role to ensure the handlers inside that role are statically parsed at the start of playbook execution, so the handlers are registered immediately and can be called anytime.

Debugging Strategy: Tracing Event Triggers Precisely #

When your event chain doesn’t run correctly, you need deeper visibility into the Ansible execution engine. Don’t guess where the failure is. Use Ansible’s built-in logging instruments.

Using Verbose CLI Modifiers (-v, -vv) #

You can increase the terminal output verbosity level when running ansible-playbook to see the handler triggering process in real time.

# Run the playbook with verbosity level 2 to see handler notifications
ansible-playbook -i hosts.ini site.yml -vv

At the -vv output level, watch for lines showing event registration like below:

META: ran handlers
NOTIFIED HANDLER webserver | Restart Nginx for host-01

If the NOTIFIED HANDLER line doesn’t appear after the relevant task executes, that means the task status is ok (not changed), or there’s a notification name typo so Ansible ignores the trigger.

Checking Variable Registration Status #

Another very accurate debug method is registering task output into a variable using register, then evaluating its value with the debug module.

# Using the debug module to verify change status
- name: Configure application parameters
  template:
    src: settings.conf.j2
    dest: /etc/settings.conf
  register: app_config_result
  notify: Reload Application

- name: Print the configuration update status detail
  debug:
    var: app_config_result.changed
  # ✓ This line prints "true" if a real change occurred,
  # confirming the notify signal should have been successfully sent.

Forcing Triggers Using changed_when #

Although the basic handler principle is only responding to real changed statuses, there are cases where the module you’re using can’t automatically detect change status. The most common example is using the command or shell modules.

The command module by default always returns a changed status because Ansible doesn’t know what that external binary command did inside the operating system. Conversely, modules like uri that make REST API calls may only return an ok status even though the data on the destination server was actually modified.

You can control this status determination precisely using the changed_when parameter to ensure accurate handler triggering.

# Scenario 1: Force a "changed" status on a shell module only if there's specific output
- name: Check the application database integrity
  shell: /usr/local/bin/check_db_schema.sh
  register: db_check
  # We know the script returns the text "SCHEMA_OUTDATED" if any table is missing
  changed_when: "'SCHEMA_OUTDATED' in db_check.stdout"
  notify: Update Database Schema

# Scenario 2: Force a "changed" status to always be True for a manual trigger task
- name: Send a manual trigger signal for cache cleanup
  command: /usr/bin/true
  changed_when: true  # ✓ Always returns a "changed" status
  notify: Clear Redis Cache

By using changed_when wisely, you keep your playbook automation accurate without breaking the idempotency principle. You only trigger restarts or recovery actions when the system condition truly needs them.


Summary #

  • Decoupling with listen: The listen keyword separates the direct dependency (tight coupling) between tasks and handlers, replacing it with a clean Publish-Subscribe model.
  • Event Topics: Tasks use notify to trigger an event, while one or more handlers listen for that event using listen: <event_name>.
  • Definition Order: Handler execution order is absolutely determined by their writing order in the handlers: definition file, not by the notify calling order in tasks.
  • Failure Causes: Trigger failures are usually caused by name typos (case-sensitive), tasks returning ok, playbook crashes before the play ends, or dynamic role scoping.
  • Debugging Instruments: Use the -vv CLI option to deeply monitor the NOTIFIED HANDLER status on the terminal screen.
  • changed_when Conditional: Use the changed_when parameter on external command modules to logically control change status before triggering handlers.

← Previous: Handler Next: Condition & Loop →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact