Introduction
Ansible tasks report "changed" on every run even when the system state is already correct. This breaks idempotency, causing unnecessary changes and polluted reports.
Symptoms
Task always changed:
```bash $ ansible-playbook site.yml
TASK [Configure nginx] *********** changed: [webserver] # Even when config already correct ```
Idempotency test shows changes:
```bash $ ansible-playbook site.yml --check --diff # Second run still shows changes
TASK [Copy config] *********** --- before: /etc/nginx/nginx.conf +++ after: /etc/nginx/nginx.conf @@ -1,3 +1,3 @@ # Shows differences when none should exist ```
Handler always triggered:
- name: Restart nginx
service:
name: nginx
state: restarted
# Triggered on every runCommon Causes
- 1.Force flag - Using force=yes unnecessarily
- 2.Template changes - Dynamic content in templates
- 3.Permission drift - Mode/owner changes detected
- 4.No change detection - Command/shell without creates
- 5.Ordering differences - YAML keys or list ordering
- 6.Whitespace changes - Trailing spaces or newlines
Step-by-Step Fix
- 1.Check logs for specific error messages
- 2.Verify configuration settings
- 3.Test network connectivity
- 4.Review recent changes
- 5.Apply corrective action
- 6.Verify the fix
Step 1: Identify Non-Idempotent Tasks
```bash # Run playbook twice: ansible-playbook site.yml ansible-playbook site.yml
# Check for changed tasks on second run: ansible-playbook site.yml --check --diff
# Use check mode to preview: ansible-playbook site.yml --check --diff
# Enable verbose output: ansible-playbook site.yml -vv
# Use Ansible Tower/AWX to track job changes # Look for tasks that always show "changed" ```
Step 2: Check File Operations
```yaml # Non-idempotent copy: - name: Copy config copy: src: nginx.conf dest: /etc/nginx/nginx.conf force: yes # Always overwrites! # Remove force: yes for idempotency
# Idempotent copy: - name: Copy config copy: src: nginx.conf dest: /etc/nginx/nginx.conf # Only copies if different
# Non-idempotent template: - name: Config file template: src: config.j2 dest: /etc/app/config.yml # Check if template generates same output
# Check template rendering: ansible -m template -a "src=config.j2 dest=/tmp/test.yml" localhost
# File permissions causing changes: - name: Set file mode file: path: /etc/app/config.yml mode: '0644' owner: root group: root # Check actual permissions match ```
Step 3: Fix Command Idempotency
```yaml # Non-idempotent command: - name: Run migration command: python manage.py migrate # Always reports changed!
# Fix with creates: - name: Run migration command: python manage.py migrate args: creates: /var/lib/app/.migrated # Only runs if marker file missing
# Fix with changed_when: - name: Run migration command: python manage.py migrate register: migrate_result changed_when: "'No migrations to apply' not in migrate_result.stdout"
# Fix with check mode: - name: Run migration command: python manage.py migrate --check register: check_result changed_when: false failed_when: false
- name: Run actual migration
- command: python manage.py migrate
- when: check_result.rc != 0
`
Step 4: Fix Shell Script Idempotency
```yaml # Non-idempotent shell: - name: Add line to file shell: echo "new line" >> /etc/hosts # Always adds the line!
# Use lineinfile instead: - name: Add line to file lineinfile: path: /etc/hosts line: "192.168.1.1 server1" state: present # Idempotent - only adds if not present
# Non-idempotent script: - name: Run setup script script: setup.sh # Always runs!
# Fix with creates: - name: Run setup script script: setup.sh args: creates: /opt/app/installed
# Or check inside script: #!/bin/bash if [ -f /opt/app/installed ]; then echo "Already installed" exit 0 fi # ... installation code touch /opt/app/installed ```
Step 5: Fix Template Issues
```yaml # Template with dynamic content: # config.j2 --- generated_at: {{ ansible_date_time.iso8601 }} # Changes every run!
# Fix: Remove dynamic content from templates: --- # generated_at: {{ ansible_date_time.iso8601 }} # Removed static_value: "constant"
# Or use fact caching: - name: Get timestamp once set_fact: deploy_time: "{{ ansible_date_time.iso8601 }}" run_once: true
- name: Template config
- template:
- src: config.j2
- dest: /etc/app/config.yml
- vars:
- generated_at: "{{ deploy_time }}"
# Check template differences: ansible localhost -m template -a "src=config.j2 dest=/tmp/test.yml" -vv diff /tmp/test.yml /etc/app/config.yml ```
Step 6: Fix Permission Drift
```yaml # Permissions always changing: - name: Set directory permissions file: path: /var/log/app mode: '0755' owner: app group: app recurse: yes # Can cause issues! # Each file with different mode reports change
# Fix: Set directory only: - name: Set directory permissions file: path: /var/log/app mode: '0755' owner: app group: app state: directory
# For files inside: - name: Set file permissions file: path: "{{ item }}" mode: '0644' owner: app group: app loop: "{{ lookup('fileglob', '/var/log/app/*').split(',') }}" when: item is file
# Check actual permissions: ansible webserver -m stat -a "path=/var/log/app" ```
Step 7: Use Check Mode Properly
```yaml # Tasks should support check mode:
- name: Create user
- user:
- name: appuser
- state: present
- # Automatically supports check mode
- name: Run database migration
- command: python manage.py migrate
- check_mode: no # Skip in check mode
- # Or
- check_mode: yes # Always run in check mode
- name: Configure service
- template:
- src: service.conf.j2
- dest: /etc/service.conf
- diff: yes # Show diff in output
# Test idempotency: ansible-playbook site.yml --check --diff ansible-playbook site.yml --check --diff # Second run should show no changes ```
Step 8: Fix Package Idempotency
```yaml # Non-idempotent package: - name: Install package yum: name: nginx state: latest # Updates if new version! # Always checks for updates
# Idempotent package: - name: Install package yum: name: nginx state: present # Only installs if missing # Idempotent
# Pin version: - name: Install specific version yum: name: nginx-1.18.0 state: present # Idempotent if version matches
# For apt: - name: Install package apt: name: nginx state: present update_cache: yes cache_valid_time: 3600 # Cache valid for 1 hour ```
Step 9: Fix Service Idempotency
```yaml # Non-idempotent service: - name: Restart nginx service: name: nginx state: restarted # Always restarts! listen: "restart nginx"
# Idempotent approach: - name: Reload nginx service: name: nginx state: reloaded # Only reloads if config changed when: nginx_config.changed
# Better: Use handlers: - name: Copy nginx config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: Reload nginx
handlers: - name: Reload nginx service: name: nginx state: reloaded
# Check if service needs restart: - name: Check nginx config command: nginx -t register: config_test changed_when: false failed_when: config_test.rc != 0
- name: Reload nginx
- service:
- name: nginx
- state: reloaded
- when: config_test is changed
`
Step 10: Test Idempotency
```bash # Create idempotency test script: cat << 'EOF' > test-idempotency.sh #!/bin/bash
PLAYBOOK=$1
echo "=== First Run ===" ansible-playbook $PLAYBOOK
echo "" echo "=== Second Run (Check Mode) ===" ansible-playbook $PLAYBOOK --check --diff
echo "" echo "=== Checking for changed tasks ===" ansible-playbook $PLAYBOOK --check 2>&1 | grep "changed:" && echo "FAILED: Tasks changed!" && exit 1
echo "SUCCESS: All tasks idempotent" EOF
chmod +x test-idempotency.sh
# Run test: ./test-idempotency.sh site.yml
# Use Molecule for testing: molecule test # Includes idempotency test by default
# Ansible-lint rules: ansible-lint site.yml # Check for idempotency issues ```
Ansible Idempotency Checklist
| Check | Command | Expected |
|---|---|---|
| Second run | ansible-playbook | No changes |
| Check mode | --check --diff | No diff |
| Force flags | grep playbook | Not used unnecessarily |
| changed_when | grep playbook | Properly set |
| Handlers | grep playbook | Used correctly |
| Templates | check output | No dynamic content |
Verification
```bash # After fixing idempotency
# 1. Run playbook first time ansible-playbook site.yml // Some tasks changed
# 2. Run playbook second time ansible-playbook site.yml // No tasks changed (ok only)
# 3. Run with check mode ansible-playbook site.yml --check --diff // No changes shown
# 4. Check specific task ansible-playbook site.yml --check -vv | grep "changed" // No output
# 5. Test with Molecule molecule test // Idempotency test passes
# 6. Verify handlers # Handler only triggers when needed // Correct behavior ```
Related Issues
- [Fix Ansible Handler Not Running](/articles/fix-ansible-handler-not-running)
- [Fix Ansible Task Timeout](/articles/fix-ansible-async-task-timeout)
- [Fix Ansible Variable Undefined](/articles/fix-ansible-set-fact)
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": "Fix Ansible Task Idempotency Failed", "description": "Troubleshoot Ansible idempotency. Check changed detection, use check mode, conditions.", "url": "https://www.fixwikihub.com/fix-ansible-task-idempotency-failed", "publisher": { "@type": "Organization", "name": "FixWikiHub", "url": "https://www.fixwikihub.com" }, "author": { "@type": "Person", "name": "FixWikiHub Editorial Team" }, "datePublished": "2026-04-06T07:18:30.768Z", "dateModified": "2026-04-06T07:18:30.768Z" } </script>