Idempotent Ansible Tasks: Using creates, changed_when, and stat Correctly
Idempotency is a property of each Ansible task, not the playbook. How creates, changed_when and stat keep command and shell tasks from reporting changes on every run.
06 Aug 2025, 05:22 UTC

The short answer
Idempotency in Ansible is a property of a task, not of a playbook. A task is idempotent when running it against a host already in the desired state produces no change. Ansible reports this through the changed flag: changed=0 on a repeat run means the task found nothing to do.
Most built-in modules are written to be idempotent. The exceptions are ansible.builtin.command and ansible.builtin.shell, which run whatever you give them and report changed every time. The practical engineering decision is how to bring those two modules under control, and how to verify that you did.
How Ansible decides a task changed something
Each module returns a result dictionary, and the changed key is set by the module itself based on what it observed. copy compares the source checksum against the destination and returns changed: false when they match. stat only reads metadata, so it never reports a change. command has no way to know whether the command altered anything, so it assumes it did.
Handlers are triggered by that flag. A task that wrongly reports changed restarts services on every run; a task that wrongly reports ok leaves a service running stale configuration. Both are real failures, which is why the flag is worth getting right.
Worked example: a migration that runs once
Assume a host where an application migration must run after deployment but must not run twice. The example uses fully qualified collection names, which ansible-core 2.10 and later expect.
- name: Apply application migrations
hosts: appservers
become: true
become_user: myapp
vars:
app_root: /opt/myapp
migration_marker: "{{ app_root }}/.migrated-2026-10"
tasks:
- name: Apply pending migrations
ansible.builtin.command:
cmd: "{{ app_root }}/bin/migrate --config {{ app_root }}/config.yml"
creates: "{{ migration_marker }}"
register: migration
- name: Show whether migrations ran
ansible.builtin.debug:
msg: "changed={{ migration is changed }}"
The key is creates. Before running the command, the command module checks whether that path exists. If it does, the task is skipped and reports no change. If it does not, the command runs and reports changed.
The catch: creates does not create anything. The migration script itself must write {{ migration_marker }} when it succeeds. If the script never touches that file, the task runs on every playbook execution and you have gained nothing.
Run the playbook twice. The first run should show changed=1; the second should show changed=0 with the task reported as skipped.
Two other patterns, and when each applies
changed_when: false for read-only commands
Some commands genuinely must run every time: a health probe, a version query, a checksum comparison. They read state without altering it, so reporting changed is wrong.
- name: Read the application version
ansible.builtin.command:
cmd: "{{ app_root }}/bin/myapp --version"
register: app_version
changed_when: false
Use this only when the command truly does not modify the host. Applying changed_when: false to a command that writes files or restarts services suppresses handler notifications and hides real drift.
stat plus when for conditions that are not plain file existence
creates only tests whether a path exists. When the condition is richer, gather the fact first with ansible.builtin.stat, which is read-only and safe, then gate the task with when.
- name: Check for the installed version marker
ansible.builtin.stat:
path: "{{ app_root }}/VERSION"
register: version_file
- name: Run the upgrade only when the marker is missing
ansible.builtin.command:
cmd: "{{ app_root }}/bin/upgrade"
when: not version_file.stat.exists
stat returns metadata such as exists, size, mode, mtime and a checksum, and never modifies the host. Keep the condition as simple as the fact you actually need; a checksum comparison is usually better handled by copy or template than by a hand-rolled when.
Verifying idempotency
Two runs are the minimum evidence. From the control node, with the inventory and playbook paths substituted:
# 1. Preview what would change
ansible-playbook -i inventory.ini site.yml --check --diff
# 2. Apply
ansible-playbook -i inventory.ini site.yml
# 3. Apply again; the recap should show changed=0
ansible-playbook -i inventory.ini site.yml
Read the PLAY RECAP line at the end. changed=0 across all hosts on the third command is the signal you want. --diff shows file-level differences for modules that support it, which makes unexpected changes easier to attribute.
One caveat about --check: command and shell tasks are not executed in check mode by default, because Ansible cannot predict their effect. They appear as skipped. Check mode alone therefore cannot prove those tasks are idempotent; only a real second run can.
Common mistakes
- Assuming
createscreates the file. It is a guard, not an action. The command must produce the marker. - Appending with
shell.shell: echo "x" >> /etc/confadds a line on every run. Useansible.builtin.lineinfileorblockinfile, which compare before writing. - Silencing a task that does change state.
changed_when: falseon a write operation stops handlers from firing and makes the recap misleading. - Trusting
state: touch. Thefilemodule withstate: touchupdates timestamps unconditionally and reportschangedeach time. Usestate: filewhen you only need the file to exist. - Forgetting custom modules. A module you write yourself must implement check mode and set
changedaccurately; Ansible cannot infer either.
Limits worth knowing
Idempotency is local to the task. A playbook can contain ten idempotent tasks and still be non-idempotent overall if their ordering or shared state conflicts. It also says nothing about convergence time or about whether the desired state is correct: a task can be perfectly repeatable and still configure the wrong thing.
Finally, "no change reported" is only as trustworthy as the module. Modules that compare content by checksum, such as copy and template, are reliable here. Modules that act unconditionally, such as command, shell and file with state: touch, are not, and need the guards shown above.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.