Skip to main content
RunBook Academy

AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting

How Ansible decides ok versus changed

Intermediate⏱ ~18 minansible-playbook

What you'll learn

  • Describe the read, compare, act, report cycle a module performs
  • Define each result state precisely and say what it asserts about the host
  • Distinguish results that establish a fact from results that establish nothing
  • Read a mixed recap and say what happened to the estate

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

changed is the most consequential word Ansible produces. It fires handlers, it populates the audit trail, it is the number a drift detector watches, and it is the thing a CI gate asserts.

It is also produced entirely by the module, using logic the module author wrote. Understanding where it comes from is what lets you judge whether to believe it.

The decision, step by step

A well-written module executes four steps on the managed node:

  1. Read. Determine the current state of the resource. For ansible.builtin.user that is a getpwnam lookup; for ansible.builtin.copy it is a checksum of the destination; for ansible.builtin.service it is a query to the service manager.
  2. Compare. Diff the current state against the declared parameters, attribute by attribute. Only the attributes you declared are compared — ansible.builtin.file with no mode does not care what the mode is.
  3. Act. If and only if something differs, perform the minimal change that removes the difference. Not “reapply everything” — change the one attribute that was wrong.
  4. Report. Return changed: true if step 3 did anything, false otherwise.

Two things follow directly.

A module reports changed for the resource, not for the run. A user task that corrects only the shell reports changed: true identically to one that created the account from nothing. The recap counts tasks that changed something; it does not weigh them.

Only declared attributes participate. This is why a task can report ok on a host whose configuration is visibly wrong: the wrong thing was an attribute nobody declared. Idempotency is relative to the declaration, and a thin declaration converges to a thin state.

The full result vocabulary

Seven states. Five are per-task results, and two only appear in the recap.

ok

The task ran to completion and reported no change. For a declarative module this is a positive assertion: the module read the resource, compared it, and found it already matching.

For ansible.builtin.debug, ansible.builtin.assert and ansible.builtin.set_fact, ok means only that the task did its job — those modules do not change host state and never report changed on their own.

changed

The module performed an action that altered the resource. Or — and this is the whole reason Part XII exists — the module believes it did, or an author’s changed_when said so.

changed triggers notify. That single fact is why an inaccurate one is not cosmetic.

failed

The task ran and reported failure: a package that does not exist, a service that would not start, a template that could not be rendered, a command that exited non-zero. The connection worked. The host or the declaration is at fault.

A failed host is removed from the play and runs nothing further.

skipped

The task was not executed. Three distinct causes produce this one word:

  • A when: condition evaluated false.
  • The task’s tags did not match a --tags or --skip-tags selection.
  • The run is in check mode and the module does not support it.

None of them tells you anything about the host’s state. A skipped result is the absence of information.

unreachable

Ansible could not establish a usable connection. This is a statement about the network, the host, SSH or the remote interpreter — never about your playbook.

Critically, a task that never connects cannot produce this result. A play built from debug reports unreachable=0 for a host that is switched off.

rescued (recap only)

A task failed inside a block and the block’s rescue section handled it. The failure was real; recovery happened. failed does not count it.

ignored (recap only)

A task failed under ignore_errors: true. The failure was real and was discarded. failed does not count it either.

All seven in one run

Read-only / Safethe vocabulary, demonstrated
$ ansible-playbook -i inv2.ini vocab.yml
TASK [A module that inspected nothing and reports changed anyway] **************
changed: [localhost]

TASK [The same command, told the truth about itself] ***************************
ok: [localhost]

TASK [The same command, judged on its own evidence] ****************************
ok: [localhost]

TASK [A task whose condition is false] *****************************************
skipping: [localhost]

TASK [A non-zero exit that is not a failure] ***********************************
ok: [localhost]

TASK [A real failure that was swallowed] ***************************************
fatal: [localhost]: FAILED! => {"changed": false, "cmd": ["/bin/false"], "msg": "The command exited with a non-zero return code.", "rc": 1}
...ignoring

TASK [Fail inside the block] ***************************************************
fatal: [localhost]: FAILED! => {"changed": false, "cmd": ["/bin/false"], "msg": "The command exited with a non-zero return code.", "rc": 1}

TASK [Recover] *****************************************************************
ok: [localhost] => {
  "msg": "recovered"
}

PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=1    unreachable=0    failed=0    skipped=1    rescued=1    ignored=1

Read that recap and describe the run out loud. failed=0 — and three tasks failed. Two fatal: lines are printed in full, one followed by ...ignoring, and neither contributes to the failure count.

Which results establish a fact

This is the distinction that makes the vocabulary operationally useful:

ResultDoes it tell you something about the host?
ok from a declarative moduleYes — the resource matches the declaration
changed from a declarative moduleYes — it did not match, and now does
ok from debug / set_factNo — nothing on the host was inspected
changed from command / shellNo — the module cannot know what it did
skippedNo — nothing was inspected
failedYes — the host or declaration is wrong, in a stated way
unreachableYes, about the infrastructure — nothing about the host’s config

Half the table is “no”. A recap full of ok from a play built out of debug and command tasks with changed_when: false is a green run that has verified nothing whatsoever, and it looks exactly like a green run that verified everything.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A copy task declares owner, group and mode. The destination file has the right content but the wrong mode. What does the task report?

  2. Q2. Which result establishes a positive fact about the configuration of the host?

  3. Q3. Which causes produce a skipped result? Select all that apply.

  4. Q4. changed_when is evaluated on the controller after the module returns, and it replaces the module result rather than being combined with it.

Passing score: 75%. Answers are checked in this browser.