Ansible Idempotence, changed and ok
Idempotence is the property that running something twice has the same effect as running it once, and in Ansible it is visible in one word per host: `changed` or `ok`. This shows both, shows the modules that cannot tell you either way, and then uses check mode and `--diff` to see a change before committing to it.
Ad-hoc Commands and Modules Guide 9 of 45 Beginner
- OSUbuntu 26.04 LTS
- ansible-core2.20.1
- Python3.14.4
- TimeAbout 15 min
- Reviewed23 August 2026
Written against the versions above. Check mode is implemented per module. Modules that understand it report what they would do; `command` and `shell` cannot, so they report `SKIPPED` rather than guessing. That is the correct behaviour and it means a dry run of a playbook full of `command` tasks proves very little.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| ANS-CTL01 | 192.168.0.36 | Ubuntu 26.04 LTS | Ansible Control Node | 2 Core | 3 GB | 50 GB |
| ANS-A01 | 192.168.0.37 | Ubuntu 26.04 LTS | Managed Node (group: web) | 2 Core | 3 GB | 50 GB |
| ANS-B01 | 192.168.0.38 | Ubuntu 26.04 LTS | Managed Node (group: db) | 2 Core | 3 GB | 50 GB |
Before you start
- A working control node and two managed nodes.
- The session creates
/tmp/idemon both nodes, changes a file, and removes everything at the end.
-
The configuration this guide assumes
Everything below runs from the home directory on ANS-CTL01, where two files sit side by side.
~/ansible.cfg- found because Ansible looks for./ansible.cfgin the current directory:[defaults] inventory = ./inventory.ini host_key_checking = False remote_user = sysadmin result_format = yamlresult_format = yamlrather than thestdout_callback = yamlthat older material recommends: that callback lived incommunity.generaland was removed in version 12.0.0, so a playbook run against this config errors out with "The 'community.general.yaml' callback plugin has been removed." The built-in option replaces it and produces the same readable output.~/inventory.ini- two groups and a parent group, so host patterns have something to select:[web] ans-a01 [db] ans-b01 [production:children] web db [web:vars] app_port=8080 [all:vars] ansible_python_interpreter=/usr/bin/python3The short names resolve because
/etc/hostson all three machines carries them, and the control node reaches both managed nodes with an ed25519 key and passwordless sudo.If any of this is unfamiliar, build it first: guide 3, guide 4 and guide 5 cover the three pieces in order.
The last two commands are the check worth running before anything else in this path:
pongfrom both hosts, androotwhen escalation is on.bash Example session cat ~/ansible.cfg[defaults]inventory = ./inventory.inihost_key_checking = Falseremote_user = sysadminresult_format = yamlcat ~/inventory.ini[web]ans-a01 [db]ans-b01 [production:children]webdb [web:vars]app_port=8080 [all:vars]ansible_python_interpreter=/usr/bin/python3grep -E "ans-(ctl|a|b)01" /etc/hosts192.168.0.36 ans-ctl01192.168.0.37 ans-a01192.168.0.38 ans-b01ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub-rw------- 1 sysadmin sysadmin 411 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519-rw-r--r-- 1 sysadmin sysadmin 99 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519.pubansible-inventory --graph@all: |--@ungrouped: |--@production: | |--@web: | | |--ans-a01 | |--@db: | | |--ans-b01ansible all -m ansible.builtin.pingans-a01 | SUCCESS => { "changed": false, "ping": "pong"}ans-b01 | SUCCESS => { "changed": false, "ping": "pong"}ansible all -m ansible.builtin.command -a "id -un" --becomeans-a01 | CHANGED | rc=0 >>rootans-b01 | CHANGED | rc=0 >>rootExpected resultBoth config files as shown, and
pongplusrootfrom both managed nodes.Success conditionYour control node matches the one every command in this guide was run on.
-
changed, then ok
The same command twice. First run:
ans-a01 | CHANGED => { "changed": true,Second run:
ans-a01 | SUCCESS => { "changed": false,Nothing was done the second time. The module looked at the directory, found the state already correct, and reported success without touching anything. The same holds for
copywith identical content.This is what makes a playbook safe to run repeatedly, and it is why 'converged' is a meaningful idea: a run where every task reports
okmeans the machines already match the description.The recap line at the end of a playbook counts these, and
changed=0on a second run is the quickest proof that a playbook is genuinely idempotent.bash Example session ansible all -m ansible.builtin.file -a "path=/tmp/idem state=directory mode=0755"ans-a01 | CHANGED => { "changed": true, "gid": 1000, "group": "sysadmin", "mode": "0755", "owner": "sysadmin", "path": "/tmp/idem", "size": 40, "state": "directory", "uid": 1000}ans-b01 | CHANGED => { "changed": true, "gid": 1000, "group": "sysadmin", "mode": "0755", "owner": "sysadmin", "path": "/tmp/idem", "size": 40, "state": "directory", "uid": 1000}ansible all -m ansible.builtin.file -a "path=/tmp/idem state=directory mode=0755"ans-a01 | SUCCESS => { "changed": false, "gid": 1000, "group": "sysadmin", "mode": "0755", "owner": "sysadmin", "path": "/tmp/idem", "size": 40, "state": "directory", "uid": 1000}ans-b01 | SUCCESS => { "changed": false, "gid": 1000, "group": "sysadmin", "mode": "0755", "owner": "sysadmin", "path": "/tmp/idem", "size": 40, "state": "directory", "uid": 1000}ansible all -m ansible.builtin.copy -a "dest=/tmp/idem/app.conf content='mode = production\n' mode=0644"ans-a01 | CHANGED => { "changed": true, "checksum": "3f0b6f78533af90de500b414711aced2aa374059", "dest": "/tmp/idem/app.conf", "gid": 1000, "group": "sysadmin", "md5sum": "6393acf23518641fd92508ba84002836", "mode": "0644", "owner": "sysadmin", "size": 18, "src": "/home/sysadmin/.ansible/tmp/ansible-tmp-1787471811.6940918-14279-257858629508202/.source.conf", "state": "file", "uid": 1000}ans-b01 | CHANGED => { "changed": true, "checksum": "3f0b6f78533af90de500b414711aced2aa374059", "dest": "/tmp/idem/app.conf", "gid": 1000, "group": "sysadmin", "md5sum": "6393acf23518641fd92508ba84002836", "mode": "0644", "owner": "sysadmin", "size": 18, "src": "/home/sysadmin/.ansible/tmp/ansible-tmp-1787471811.6954412-14280-159283224048728/.source.conf", "state": "file", "uid": 1000}ansible all -m ansible.builtin.copy -a "dest=/tmp/idem/app.conf content='mode = production\n' mode=0644"ans-a01 | SUCCESS => { "changed": false, "checksum": "3f0b6f78533af90de500b414711aced2aa374059", "dest": "/tmp/idem/app.conf", "gid": 1000, "group": "sysadmin", "mode": "0644", "owner": "sysadmin", "path": "/tmp/idem/app.conf", "size": 18, "state": "file", "uid": 1000}ans-b01 | SUCCESS => { "changed": false, "checksum": "3f0b6f78533af90de500b414711aced2aa374059", "dest": "/tmp/idem/app.conf", "gid": 1000, "group": "sysadmin", "mode": "0644", "owner": "sysadmin", "path": "/tmp/idem/app.conf", "size": 18, "state": "file", "uid": 1000}Expected resultCHANGED on the first run of each, ok on the second.
Success conditionYou can tell whether a run actually did anything.
-
The modules that always say changed
ans-a01 | CHANGED | rc=0 >> 1787...ans-a01 | CHANGED | rc=0 >> 1787...Both runs
CHANGED, andshell -a "true"- which does nothing at all, by definition - reportsCHANGEDas well.commandandshellcannot know. They run an arbitrary binary and have no way to tell whether it altered the system, so they assume the worst. A playbook ofcommandtasks reports change every single time and tells you nothing.The fixes, in order of preference: use a real module; failing that
creates=/removes=as in guide 11; failing thatchanged_when:to decide from the output.bash Example session ansible web -m ansible.builtin.command -a "date +%s"ans-a01 | CHANGED | rc=0 >>1787471813ansible web -m ansible.builtin.command -a "date +%s"ans-a01 | CHANGED | rc=0 >>1787471814ansible web -m ansible.builtin.shell -a "true"ans-a01 | CHANGED | rc=0 >>Expected resultThree CHANGED results from commands that changed nothing.
Success conditionYou will not trust a
changedcount fromcommandtasks. -
Check mode shows you first
--checkis a dry run:ans-a01 | CHANGED => { "changed": true }It says it would change. And the file is untouched:
ans-a01 | CHANGED | rc=0 >> mode = productionStill
production, notstaging.This is the answer to 'what will this playbook do to production'. Run it with
--checkand read the change count - though with the caveat from the previous step, thatcommandtasks are skipped rather than predicted.bash Example session ansible all -m ansible.builtin.copy -a "dest=/tmp/idem/app.conf content='mode = staging\n' mode=0644" --checkans-a01 | CHANGED => { "changed": true}ans-b01 | CHANGED => { "changed": true}ansible all -m ansible.builtin.command -a "cat /tmp/idem/app.conf"ans-a01 | CHANGED | rc=0 >>mode = productionans-b01 | CHANGED | rc=0 >>mode = productionExpected resultA predicted change, and a file that did not change.
Success conditionYou can preview a change without making it.
-
diff shows what would change
--check --difftogether give you the content, not just the verdict:--- before: /tmp/idem/app.conf +++ after: /tmp/idem/app.conf @@ -1 +1 @@ -mode = production +mode = stagingA unified diff, before anything happens. For configuration files this is the single most useful pair of flags Ansible has, and
--check --diffon a playbook you did not write is the civilised way to find out what it is going to do.Run without
--check, the same diff is printed as the change is applied, and the file really does change:ans-a01 | CHANGED | rc=0 >> mode = stagingKeeping
--diffon by default -diff = Trueunder[defaults]- is a habit worth having.bash Example session ansible web -m ansible.builtin.copy -a "dest=/tmp/idem/app.conf content='mode = staging\n' mode=0644" --check --diff 2>&1 | sed -n '/---/,/^ans-a01/p' | head -12--- before: /tmp/idem/app.conf+++ after: /tmp/idem/app.conf@@ -1 +1 @@-mode = production+mode = staging ans-a01 | CHANGED => {ansible web -m ansible.builtin.copy -a "dest=/tmp/idem/app.conf content='mode = staging\n' mode=0644" --diff 2>&1 | grep -E "^\+|^-|changed" | head -6--- before: /tmp/idem/app.conf+++ after: /tmp/idem/app.conf-mode = production+mode = staging "changed": true,ansible web -m ansible.builtin.command -a "cat /tmp/idem/app.conf"ans-a01 | CHANGED | rc=0 >>mode = stagingExpected resultA diff in check mode, then the same change applied.
Success conditionYou can see the exact content change a task will make.
-
Check mode is not free
ans-a01 | SKIPPEDcommandin check mode is skipped entirely. It did not run and it did not predict - Ansible cannot dry-run an arbitrary binary, so it declines to try. That is honest, and it is the limitation to remember: a--checkrun of a playbook that leans oncommandverifies far less than the change count suggests.Other modules do implement it properly:
ans-a01 | CHANGED => { "changed": true, "dest": "/tmp/idem/from-check" }filepredicted the touch without performing it.Two more caveats worth carrying:
- A task whose input depends on an earlier task's result may behave oddly in check mode, because that earlier task did not really run.
- A module with side effects outside the machine - calling an API, for instance - may not honour check mode at all. Read its documentation before trusting a dry run against anything that matters.
bash Example session ansible web -m ansible.builtin.command -a "id -un" --checkans-a01 | SKIPPEDansible web -m ansible.builtin.file -a "path=/tmp/idem/from-check state=touch" --checkans-a01 | CHANGED => { "changed": true, "dest": "/tmp/idem/from-check"}ansible all -m ansible.builtin.file -a "path=/tmp/idem state=absent"ans-a01 | CHANGED => { "changed": true, "path": "/tmp/idem", "state": "absent"}ans-b01 | CHANGED => { "changed": true, "path": "/tmp/idem", "state": "absent"}Expected resultA skipped command task and a properly predicted file task.
Success conditionYou know how much a
--checkrun actually proves.
Troubleshooting
Every run reports
changed, even with no edits.Why:
command/shelltasks, or a module given input that differs each run.Fix:Add
creates=/removes=orchanged_when:, or switch to a stateful module.--checkreported no changes and the real run changed things.Why: Skipped
commandtasks, or a task depending on an earlier one's result.Fix:Check mode is per module. Read what was SKIPPED.
--diffprints nothing useful.Why: The module does not produce diffs, or the file is binary.
Fix:Expected for non-text changes.
copy,templateandlineinfileall diff properly.A dry run made a real change.
Why: A module that does not support check mode.
Fix:Read its documentation. Some modules always execute.