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:

yaml
- name: Restart nginx
  service:
    name: nginx
    state: restarted
  # Triggered on every run

Common Causes

  1. 1.Force flag - Using force=yes unnecessarily
  2. 2.Template changes - Dynamic content in templates
  3. 3.Permission drift - Mode/owner changes detected
  4. 4.No change detection - Command/shell without creates
  5. 5.Ordering differences - YAML keys or list ordering
  6. 6.Whitespace changes - Trailing spaces or newlines

Step-by-Step Fix

  1. 1.Check logs for specific error messages
  2. 2.Verify configuration settings
  3. 3.Test network connectivity
  4. 4.Review recent changes
  5. 5.Apply corrective action
  6. 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

CheckCommandExpected
Second runansible-playbookNo changes
Check mode--check --diffNo diff
Force flagsgrep playbookNot used unnecessarily
changed_whengrep playbookProperly set
Handlersgrep playbookUsed correctly
Templatescheck outputNo 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 ```

  • [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)
  • [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>