Introduction
Ansible handlers are special tasks that run at the end of a play when notified by other tasks using the notify directive. A handler only executes when the notifying task finishes in the changed state. When a task correctly modifies the system but reports ok instead of changed, the handler is never triggered - the service isn't restarted, the configuration isn't reloaded, and the changes don't take effect.
This issue commonly occurs with:
- Shell and command modules that don't have change detection built-in
- Custom scripts that modify state but don't communicate changes
- Conditional logic in changed_when that incorrectly evaluates to false
- Modules that check state but don't actually change anything
Symptoms
The playbook runs successfully but the handler is never triggered:
``` TASK [Deploy application configuration] ******** ok: [web-server-01]
RUNNING HANDLER [Restart nginx] ************ skipping: [web-server-01]
PLAY RECAP ***************** web-server-01 : ok=3 changed=0 unreachable=0 failed=0 ```
When checking verbose output:
```bash $ ansible-playbook deploy.yml -vv TASK [Deploy application configuration] ******** task path: /home/user/deploy.yml:15 ok: [web-server-01] => {"changed": false, "stdout": "Configuration applied", "stderr": ""} # Handler is skipped because changed=false
# The configuration file was actually modified on the target $ ssh web-server-01 "cat /etc/nginx/conf.d/app.conf" upstream backend { server 10.0.1.50:8080; # New backend - but nginx not reloaded } ```
Debug output shows the task result:
- name: Debug task output
debug:
var: deploy_result
# Output shows:
# deploy_result.changed: false
# deploy_result.stdout: "Updated 3 records"
# But records were actually changed!Checking handler notification state:
```bash $ ansible-playbook site.yml --check --diff TASK [Update configuration] ************ --- before: /etc/app/config.yml +++ after: /etc/app/config.yml @@ -1,3 +1,3 @@ -setting: old_value +setting: new_value
changed: [server-01]
# But without --check, the task reports ok: TASK [Update configuration] ************ ok: [server-01] ```
Common Causes
**1. Command/Shell Modules Default to changed_when: false**
Unlike file-based modules, command and shell don't automatically detect changes:
- name: Update configuration
command: /usr/local/bin/update_config.sh
# This ALWAYS reports changed=false unless you define changed_when**2. Incorrect changed_when Condition**
The condition logic doesn't match the actual output:
- name: Enable feature
command: appctl enable feature-x
register: result
changed_when: "'enabled' in result.stdout"
# But stdout says "Feature feature-x is now enabled"
# The substring 'enabled' is present, but...
# If output is "Feature already enabled", this also matches incorrectly3. Idempotent Commands Always Report Same Output
Commands that check state before making changes may not change anything on subsequent runs:
- name: Create database
command: createdb myapp
register: result
changed_when: result.rc == 0
# But createdb returns 0 even if database already exists
# Need to check stderr or stdout for "already exists"4. Module-Specific Change Detection Issues
Some modules have quirks in change detection:
- name: Set sysctl value
sysctl:
name: net.core.somaxconn
value: "65535"
# This may report ok if value was already set via other means
# Even though sysctl.conf wasn't updated5. Conditional Logic Errors
Complex conditions in changed_when that evaluate unexpectedly:
- name: Deploy application
command: /opt/deploy.sh
register: deploy
changed_when:
- deploy.stdout | search('Deployment complete')
- deploy.rc == 0
# Jinja2 search filter returns None if not found, not False
# This causes the condition to evaluate unexpectedlyStep-by-Step Fix
Step 1: Diagnose Why the Task Reports `ok`
Run the playbook with high verbosity to see task output:
```bash # See detailed task output ansible-playbook site.yml -vvv --limit server-01 2>&1 | grep -A20 "TASK [.*]"
# Or use debug task to inspect the register variable - name: Debug register output debug: var: task_result verbosity: 1 ```
Check the actual change on the target:
# Before running playbook
ssh server-01 "cat /etc/app/config.yml"
ansible-playbook site.yml --check --diff # See what would change
ansible-playbook site.yml # Run actual playbook
ssh server-01 "cat /etc/app/config.yml" # Verify change happenedStep 2: Fix Change Detection for Command/Shell Tasks
Define proper changed_when logic based on command output:
```yaml - name: Update application configuration shell: | set -o pipefail /usr/local/bin/update_config.sh 2>&1 | tee /tmp/update.log register: config_update changed_when: - config_update.rc == 0 - "'No changes needed' not in config_update.stdout" failed_when: config_update.rc != 0 notify: Restart application
- name: Create database (if not exists)
- command: createdb myapp
- register: db_create
- changed_when: "'already exists' not in db_create.stderr"
- failed_when:
- - db_create.rc != 0
- - "'already exists' not in db_create.stderr"
- notify: Initialize database
`
For complex change detection, use a custom filter:
- name: Deploy and check for changes
shell: /opt/deploy.sh
register: deploy_result
changed_when: deploy_result.stdout | regex_search('Deployed (\\d+) files') | default('0') | int > 0
notify: Restart applicationStep 3: Use State-Aware Modules Instead of Commands
Replace shell commands with proper Ansible modules:
```yaml # Instead of: - name: Create user command: useradd -m -s /bin/bash appuser register: user_create changed_when: "'already exists' not in user_create.stderr"
# Use: - name: Create user user: name: appuser shell: /bin/bash create_home: yes state: present notify: Restart application ```
Replace file manipulation commands:
```yaml # Instead of: - name: Update config file shell: | sed -i 's/old_value/new_value/g' /etc/app/config.yml register: sed_result changed_when: true # Always reports changed!
# Use: - name: Update config file lineinfile: path: /etc/app/config.yml regexp: '^setting=' line: 'setting=new_value' backrefs: yes notify: Restart application ```
Step 4: Implement Robust Change Detection Patterns
Create a reusable pattern for detecting changes:
```yaml - name: Run deployment script with change detection block: - name: Get pre-deployment state command: md5sum /opt/app/app.jar register: pre_state changed_when: false failed_when: false
- name: Run deployment
- command: /opt/deploy.sh
- register: deploy
- name: Get post-deployment state
- command: md5sum /opt/app/app.jar
- register: post_state
- changed_when: false
- failed_when: false
- name: Set changed fact
- set_fact:
- deployment_changed: "{{ pre_state.stdout != post_state.stdout }}"
- name: Notify handler based on actual change
- debug:
- msg: "Deployment changed application files"
- changed_when: deployment_changed
- notify: Restart application
- when: deployment_changed
`
Step 5: Debug Handler Notification
Add debugging to understand handler behavior:
```yaml - name: Update configuration template: src: app.conf.j2 dest: /etc/app/app.conf register: config_result notify: Restart nginx
- name: Debug handler notification
- debug:
- msg: |
- Task changed: {{ config_result.changed }}
- Handler will be notified: {{ config_result.changed }}
- Task stdout: {{ config_result.stdout | default('N/A') }}
- Task stderr: {{ config_result.stderr | default('N/A') }}
- name: Force handler execution for debugging
- meta: flush_handlers
`
Step 6: Use `changed_when` with Failed Detection
Handle both success and failure conditions:
```yaml - name: Run database migration command: python manage.py migrate register: migration changed_when: - migration.rc == 0 - "'No migrations to apply' not in migration.stdout" failed_when: migration.rc != 0 notify: - Clear cache - Restart application
- name: Install package with yum
- command: yum install -y mypackage
- register: yum_install
- changed_when: yum_install.stdout is search('Installed:')
- failed_when:
- - yum_install.rc != 0
- - yum_install.stderr is not search('Nothing to do')
- notify: Start service
`
Step 7: Create a Handler Debug Playbook
Create a standalone playbook to debug handler issues:
```yaml # debug_handlers.yml - name: Debug handler notification hosts: "{{ target | default('all') }}" gather_facts: false vars: test_task: "Update configuration"
tasks: - name: {{ test_task }} command: echo "Test output" register: test_result
- name: Show task result
- debug:
- msg:
- - "Task name: {{ test_task }}"
- - "Changed: {{ test_result.changed }}"
- - "RC: {{ test_result.rc }}"
- - "Stdout: {{ test_result.stdout }}"
- - "Stderr: {{ test_result.stderr }}"
- name: Set changed status explicitly
- command: echo "Forcing change detection"
- changed_when: true
- notify: Test handler
- when: force_change | default(false)
handlers: - name: Test handler debug: msg: "Handler was triggered!" ```
Verification
Test that handlers are triggered correctly:
```bash # Run with verbose output to see handler execution ansible-playbook site.yml -v
# Expected output: TASK [Update configuration] ************ changed: [server-01]
RUNNING HANDLER [Restart nginx] ************ changed: [server-01]
# Verify the service was restarted ansible server-01 -m shell -a "systemctl status nginx | grep 'Active:'" # Should show recently restarted timestamp
# Test idempotency - handler should not trigger on second run ansible-playbook site.yml -v # Expected: handler skipped because task reports ok (no change)
# Use check mode to preview changes ansible-playbook site.yml --check --diff # Shows what would change without making changes ```
Verify change detection logic:
```bash # Test specific task with debug ansible-playbook site.yml --tags "config" -vv
# Check the registered variable value ansible server-01 -m debug -a "msg={{ hostvars[inventory_hostname]['task_result'] }}" ```
Related Issues
- [ansible-handler-not-triggered-notify-missing](/articles/ansible-handler-not-triggered-notify-missing)
- [ansible-idempotency-issues-shell-commands](/articles/ansible-idempotency-issues-shell-commands)
- [ansible-service-restart-not-applied](/articles/ansible-service-restart-not-applied)
Related Articles
- [WordPress troubleshooting: Ansible Artifact Download Uses an Old Mi](ansible-artifact-download-uses-an-old-mirror-after-proxy-change)
- [WordPress troubleshooting: Ansible Audit Trail Misses Events Under ](ansible-audit-trail-misses-events-under-burst-load)
- [WordPress troubleshooting: Ansible Background Worker Gets Stuck in ](ansible-background-worker-stuck-in-a-retry-loop)
- [WordPress troubleshooting: Ansible Backup Completes but Restore Fai](ansible-backup-completes-but-restore-fails-checksum-validation)
- [WordPress troubleshooting: Ansible Batch Importer Duplicates Rows A](ansible-batch-importer-duplicates-rows-after-a-retry)
<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "TechArticle", "headline": "WordPress troubleshooting: Ansible Handler Not Notified Because the", "description": "Learn how to fix Ansible Handler Not Notified Because the Task Never Reports Changed. Professional WordPress troubleshooting solutions with step-by-step guidance. WP error fix, WordPress optimization, WP security, WordPress performance.", "url": "https://www.fixwikihub.com/ansible-handler-not-notified-task-changed-never-triggers", "publisher": { "@type": "Organization", "name": "FixWikiHub", "url": "https://www.fixwikihub.com" }, "author": { "@type": "Person", "name": "FixWikiHub Editorial Team" }, "datePublished": "2026-01-22T15:36:08.049Z", "dateModified": "2026-01-22T15:36:08.049Z" } </script>