Skip to main content
Ansible · lesson 1 of 6
Ansible beginner 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 playbooks idempotency getting-started

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.

Frequently Asked Questions

What does changed=0 in the PLAY RECAP actually mean?
It means every task in the play checked the current state of the system, found it already matched what was declared, and did nothing — as opposed to ok=N, which just means the task ran without erroring. A well-written Ansible module always checks state first; changed only appears when it genuinely had to modify something.
Why did the copy module report ok on the second run instead of changed?
The copy module hashes the file it's about to write and compares that hash to what's already at the destination — if they match, it reports ok and skips the actual write. This built-in state check is what makes copy (and most core Ansible modules) idempotent without you writing any conditional logic yourself.
Why is the shell module different from copy — why does it report changed every time?
shell has no idea what "success" looks like for an arbitrary command — it can't inspect a shell command's intent, so by design it reports changed on every successful run unless you explicitly tell it otherwise (with creates:, removes:, or a custom changed_when condition). This is exactly why reaching for shell instead of a real module quietly breaks idempotency.
Is ansible_connection=local the same as running commands directly on this machine?
Functionally yes for a single-host lesson like this one — it tells Ansible to skip SSH entirely and execute tasks as a local subprocess against localhost. Production Ansible almost always manages remote hosts over SSH; local is what makes a fully offline, no-inventory-setup lesson possible here.
Do I need an inventory file for a single local host?
Technically no — ansible-playbook -i localhost, accepts a comma-separated inline inventory for quick one-offs. A real inventory.ini file is used here because every lesson after this one builds on group_vars/host_vars, which need a named group to attach to.