Ansible · lesson 1 of 6
Also part of: DevOps
Ansible Idempotency, Verified: Run It Twice and Watch changed=0
Ansible's whole pitch is idempotency — running a playbook twice should only change things the first time. Verify that claim by actually running one twice, then break it on purpose with a shell task.
By SkillByExample · Updated
Ansible’s entire pitch is idempotency: run a playbook once, run it again, and the second run should change nothing. That’s a testable claim, not a slogan — so test it.
A first playbook
# inventory.ini
[local]
localhost ansible_connection=local
# playbook.yml
---
- name: My first playbook
hosts: local
tasks:
- name: Print a greeting
debug:
msg: "Hello from Ansible"
ansible-playbook -i inventory.ini playbook.yml
PLAY [My first playbook] *******************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Print a greeting] ********************************************************
ok: [localhost] => {
"msg": "Hello from Ansible"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Two tasks ran (Gathering Facts is automatic — Ansible collects host information before your
own tasks), both ok, changed=0. debug never changes anything by definition, so this run
proves nothing about idempotency yet.
Idempotency, actually verified
# file_playbook.yml
---
- name: Create a file
hosts: local
tasks:
- name: Ensure a file exists
copy:
content: "hello from ansible\n"
dest: /tmp/greeting.txt
ansible-playbook -i inventory.ini file_playbook.yml
TASK [Ensure a file exists] ****************************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
First run: changed=1 — the file didn’t exist, so Ansible created it. Run the exact same
playbook again, with nothing on disk touched in between:
ansible-playbook -i inventory.ini file_playbook.yml
TASK [Ensure a file exists] ****************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 this time. The copy module hashed the file it was about to write, compared that
hash against what already exists at the destination, saw they matched, and skipped the write
entirely — this is the actual mechanism behind idempotency, not just a design philosophy.
The bug: shell breaks idempotency by design
# naive_playbook.yml
---
- name: Non-idempotent version
hosts: local
tasks:
- name: Append greeting with shell
shell: echo "hello from ansible" >> /tmp/naive.txt
ansible-playbook -i inventory.ini naive_playbook.yml
ansible-playbook -i inventory.ini naive_playbook.yml
cat /tmp/naive.txt
TASK [Append greeting with shell] **********************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
TASK [Append greeting with shell] **********************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hello from ansible
hello from ansible
changed=1 on both runs — and the file now has two identical lines instead of one. shell
has no way to know what “already done” looks like for an arbitrary command, so it reports
changed unconditionally on every successful run. This is the single most common way a
playbook quietly stops being idempotent: reaching for shell or command where a real module
(copy, file, template, apt, service, and dozens more) already exists and already
checks state correctly.
Practice
1. Run the working `file_playbook.yml` a third time and confirm it's still changed=0.
ansible-playbook -i inventory.ini file_playbook.yml
TASK [Ensure a file exists] ****************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
No surprise — nothing about the destination file or the declared content changed between runs 2 and 3, so the hash comparison keeps matching indefinitely.
2. Change the file's declared content and observe exactly one more changed run.
# file_playbook.yml, content changed
copy:
content: "hello again from ansible\n"
dest: /tmp/greeting.txt
ansible-playbook -i inventory.ini file_playbook.yml
ansible-playbook -i inventory.ini file_playbook.yml
TASK [Ensure a file exists] ****************************************************
changed: [localhost]
...
TASK [Ensure a file exists] ****************************************************
ok: [localhost]
changed on the first run after the edit (the hash no longer matches), ok on the next
(it does again) — idempotency isn’t “never changes,” it’s “only changes when the declared
state actually differs from reality.”
3. Delete the naive.txt file the shell task built, then predict what a third run of the same shell playbook produces.
rm /tmp/naive.txt
ansible-playbook -i inventory.ini naive_playbook.yml
cat /tmp/naive.txt
TASK [Append greeting with shell] **********************************************
changed: [localhost]
hello from ansible
Back to one line, changed=1 again — the underlying command (echo >>) genuinely doesn’t
care whether the file existed before; shell’s “always changed” behavior is consistent
regardless of the file’s prior state, which is exactly the point: it never actually inspects
that state at all.
Next: variables and precedence — where a value actually comes from when several sources define it.