What's Actually Happening

Ansible tasks report "changed" on every run even when the system state is already correct. This breaks idempotency, causing unnecessary changes and polluted reports.

The Error You'll See

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

Why This Happens

  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 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

Verify the Fix

```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)