CertGrid CertGrid
Hands-on Lab·Ansible

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

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.

Both managed nodes, so `changed` and `ok` can be seen happening to two hosts independently.
Server NameIP AddressOSRolesCPURAMHDD
ANS-CTL01192.168.0.36Ubuntu 26.04 LTSAnsible Control Node2 Core3 GB50 GB
ANS-A01192.168.0.37Ubuntu 26.04 LTSManaged Node (group: web)2 Core3 GB50 GB
ANS-B01192.168.0.38Ubuntu 26.04 LTSManaged Node (group: db)2 Core3 GB50 GB

Before you start

  1. 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.cfg in the current directory:

    [defaults]
    inventory = ./inventory.ini
    host_key_checking = False
    remote_user = sysadmin
    result_format = yaml

    result_format = yaml rather than the stdout_callback = yaml that older material recommends: that callback lived in community.general and 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/python3

    The short names resolve because /etc/hosts on 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: pong from both hosts, and root when 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 >>root

    Expected resultBoth config files as shown, and pong plus root from both managed nodes.

    Success conditionYour control node matches the one every command in this guide was run on.

  2. 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 copy with 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 ok means the machines already match the description.

    The recap line at the end of a playbook counts these, and changed=0 on 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.

  3. The modules that always say changed

    ans-a01 | CHANGED | rc=0 >>
    1787...
    ans-a01 | CHANGED | rc=0 >>
    1787...

    Both runs CHANGED, and shell -a "true" - which does nothing at all, by definition - reports CHANGED as well.

    command and shell cannot know. They run an arbitrary binary and have no way to tell whether it altered the system, so they assume the worst. A playbook of command tasks 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 that changed_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 changed count from command tasks.

  4. Check mode shows you first

    --check is a dry run:

    ans-a01 | CHANGED => {
        "changed": true
    }

    It says it would change. And the file is untouched:

    ans-a01 | CHANGED | rc=0 >>
    mode = production

    Still production, not staging.

    This is the answer to 'what will this playbook do to production'. Run it with --check and read the change count - though with the caveat from the previous step, that command tasks 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 = production

    Expected resultA predicted change, and a file that did not change.

    Success conditionYou can preview a change without making it.

  5. diff shows what would change

    --check --diff together give you the content, not just the verdict:

    --- before: /tmp/idem/app.conf
    +++ after: /tmp/idem/app.conf
    @@ -1 +1 @@
    -mode = production
    +mode = staging

    A unified diff, before anything happens. For configuration files this is the single most useful pair of flags Ansible has, and --check --diff on 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 = staging

    Keeping --diff on by default - diff = True under [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 = staging

    Expected resultA diff in check mode, then the same change applied.

    Success conditionYou can see the exact content change a task will make.

  6. Check mode is not free

    ans-a01 | SKIPPED

    command in 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 --check run of a playbook that leans on command verifies far less than the change count suggests.

    Other modules do implement it properly:

    ans-a01 | CHANGED => {
        "changed": true,
        "dest": "/tmp/idem/from-check"
    }

    file predicted 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 --check run actually proves.

Troubleshooting

Official sources