AnsiblePilot — Master Ansible Automation

AnsiblePilot is the leading resource for learning Ansible automation, DevOps, and infrastructure as code. Browse over 1,400 tutorials covering Ansible modules, playbooks, roles, collections, and real-world examples. Whether you are a beginner or an experienced engineer, our step-by-step guides help you automate Linux, Windows, cloud, containers, and network infrastructure.

Popular Topics

About Luca Berton

Luca Berton is an Ansible automation expert, author of 8 Ansible books published by Apress and Leanpub including "Ansible for VMware by Examples" and "Ansible for Kubernetes by Example", and creator of the Ansible Pilot YouTube channel. He shares practical automation knowledge through tutorials, books, and video courses to help IT professionals and DevOps engineers master infrastructure automation.

How to Make Ansible Playbooks Idempotent: Complete Guide

By Luca Berton · Published 2024-01-01 · Category: installation

Complete guide to making Ansible playbooks idempotent. Learn what idempotency means, how to test for it, fix common non-idempotent patterns, use changed_when.

Idempotency means running a playbook once or a hundred times produces the same result. It's the single most important property of reliable automation. If your playbook isn't idempotent, you can't safely re-run it, and you can't trust it in CI/CD pipelines.

What Idempotent Means

# Run 1: Makes changes
$ ansible-playbook site.yml
PLAY RECAP
web01: ok=10 changed=5

# Run 2: No changes (already in desired state) $ ansible-playbook site.yml PLAY RECAP web01: ok=10 changed=0 # ← THIS is idempotent

If the second run shows changed=0, your playbook is idempotent.

See also: Ansible changed_when & failed_when: Control Task Status (Guide)

Test for Idempotency

# The idempotency test:
ansible-playbook site.yml        # Run 1 (apply changes)
ansible-playbook site.yml        # Run 2 (should show changed=0)

# Automated check: ansible-playbook site.yml 2>&1 | tail -1 | grep -q "changed=0" echo "Idempotent: $?" # 0 = yes, 1 = no

Built-In Idempotent Modules

Most Ansible modules are idempotent by design:

# ✅ IDEMPOTENT — apt checks if already installed
- ansible.builtin.apt:
    name: nginx
    state: present

# ✅ IDEMPOTENT — copy checks checksum before transferring - ansible.builtin.copy: src: config.conf dest: /etc/myapp/config.conf

# ✅ IDEMPOTENT — user checks if user exists - ansible.builtin.user: name: deploy state: present groups: wheel

# ✅ IDEMPOTENT — systemd checks current state - ansible.builtin.systemd: name: nginx state: started enabled: true

# ✅ IDEMPOTENT — lineinfile checks if line exists - ansible.builtin.lineinfile: path: /etc/hosts line: "10.0.1.10 app.internal"

See also: Ansible-Lint: Complete Guide to Linting Playbooks & Roles

Non-Idempotent Patterns and Fixes

Pattern 1: shell/command Without Guards

# ❌ NOT IDEMPOTENT — runs every time
- ansible.builtin.shell: /opt/app/bin/setup-database

# ✅ FIX 1: creates guard - ansible.builtin.shell: /opt/app/bin/setup-database args: creates: /opt/app/.db-initialized

# ✅ FIX 2: changed_when based on output - ansible.builtin.shell: /opt/app/bin/setup-database register: db_setup changed_when: "'Created' in db_setup.stdout"

# ✅ FIX 3: Check first, then run - ansible.builtin.stat: path: /opt/app/.db-initialized register: db_init

- ansible.builtin.shell: /opt/app/bin/setup-database when: not db_init.stat.exists

Pattern 2: Always Restarting Services

# ❌ NOT IDEMPOTENT — restarts every run
- ansible.builtin.systemd:
    name: nginx
    state: restarted

# ✅ FIX — use handlers (only restart when config changes) - ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: restart nginx

# In handlers/main.yml: - name: restart nginx ansible.builtin.systemd: name: nginx state: restarted

Pattern 3: Appending to Files

# ❌ NOT IDEMPOTENT — appends duplicate lines
- ansible.builtin.shell: echo "export PATH=/opt/bin:$PATH" >> /etc/profile

# ✅ FIX — use lineinfile (checks if line exists) - ansible.builtin.lineinfile: path: /etc/profile line: "export PATH=/opt/bin:$PATH"

Pattern 4: Downloading Every Time

# ❌ NOT IDEMPOTENT — downloads every run
- ansible.builtin.shell: wget https://example.com/app.tar.gz -O /tmp/app.tar.gz

# ✅ FIX — get_url with checksum (skips if already downloaded) - ansible.builtin.get_url: url: https://example.com/app.tar.gz dest: /tmp/app.tar.gz checksum: sha256:abc123...

Pattern 5: Running pip/npm install

# ⚠️ SOMETIMES IDEMPOTENT — pip checks if installed
- ansible.builtin.pip:
    name: flask
    state: present    # ✅ Checks first

# ❌ NOT IDEMPOTENT — always installs - ansible.builtin.pip: requirements: /opt/app/requirements.txt state: latest # Downloads every run

# ✅ FIX — use state: present and pin versions - ansible.builtin.pip: name: - flask==3.0.0 - gunicorn==21.2.0 state: present

Pattern 6: Git Checkout

# ✅ IDEMPOTENT — git module checks current state
- ansible.builtin.git:
    repo: https://github.com/org/app.git
    dest: /opt/app
    version: "{{ version }}"
  # Only changes if version is different

Pattern 7: Database Operations

# ❌ NOT IDEMPOTENT — creates duplicate records
- ansible.builtin.shell: |
    psql -c "INSERT INTO config (key, value) VALUES ('setting', 'value')"

# ✅ FIX — use ON CONFLICT - ansible.builtin.shell: | psql -c "INSERT INTO config (key, value) VALUES ('setting', 'value') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value" register: db_result changed_when: "'INSERT 0 1' in db_result.stdout"

# ✅ BETTER — use community.postgresql module - community.postgresql.postgresql_query: query: > INSERT INTO config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value positional_args: - setting - value

changed_when and failed_when

The two most important keywords for idempotency:

# Mark task as never changed (read-only commands)
- ansible.builtin.command: df -h /
  register: disk
  changed_when: false

# Mark changed based on output - ansible.builtin.shell: /opt/app/bin/migrate register: result changed_when: "'migrated' in result.stdout" failed_when: "'ERROR' in result.stderr"

# Mark changed based on return code - ansible.builtin.command: /opt/app/bin/check-update register: update_check changed_when: update_check.rc == 2 # rc 2 = update available failed_when: update_check.rc not in [0, 2]

See also: ARA Records Ansible: Playbook Reporting & History (Complete Guide)

Idempotency Checklist

For every task, ask: Does it check state first? — Modules like apt, copy, user check automatically Does it use changed_when? — For command/shell, define what "changed" means Does it have a guard?creates:, removes:, or when: condition Can I run it twice safely? — Will it produce the same result? Does it use handlers for restarts? — Don't restart unless config changed

FAQ

What modules are NOT idempotent by default?

command, shell, raw, and script always report "changed" because Ansible can't know if the command changed anything. Use changed_when, creates, or when to make them idempotent.

Is "changed=0" on second run enough to prove idempotency?

It's necessary but not sufficient. The system should also be in the correct state. A task with changed_when: false will always show no changes, even if it's doing something destructive. Verify actual state, not just Ansible output.

How do I test idempotency in CI/CD?

Run the playbook twice. Parse the second run's output for changed=0. Molecule (the Ansible testing framework) has built-in idempotency testing that does exactly this.

Does check mode (--check) test idempotency?

No. Check mode tests what would change on the first run. Idempotency means the second run changes nothing. They're complementary but different tests.

Conclusion

Idempotency is the difference between automation you trust and automation you fear. Use built-in modules (already idempotent), add changed_when/creates guards to shell commands, use handlers instead of direct restarts, and always test by running twice. If the second run shows changed=0, you've got it right.

Related Articles

Ansible changed_when / failed_when GuideAnsible Check Mode / Dry Run GuideAnsible shell vs command vs rawAnsible Lint: Analyze & Fix Playbooks

Category: installation

Browse all Ansible tutorials · AnsiblePilot Home