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 Guide • Ansible Check Mode / Dry Run Guide • Ansible shell vs command vs raw • Ansible Lint: Analyze & Fix PlaybooksCategory: installation