AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting
changed_when: telling the truth about a command
What you'll learn
- Derive an accurate changed result from a command exit code or its output
- Use a registered probe to make a command task report correctly
- State the difference between an accurate changed_when and a silencing one
- Know that a list of conditions is joined with and, and how to express or
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
command and shell report changed whenever they run. They have no
choice: Ansible transferred a command line, the command ran, and nothing
in that transaction tells the module whether the host is different
afterwards.
changed_when is how you supply that missing knowledge. Used well it is
the most precise instrument in the language. Used as a mute button it is
the single most damaging line you can write in a playbook, and the two
look almost identical on the page.
The keyword
changed_when takes an expression, evaluated on the controller after
the module returns, whose result replaces the module’s changed
value.
$ ansible-doc -t keyword changed_whenchanged_when:
applies_to:
- Task
- Handler
description: Conditional expression that overrides the task's normal 'changed' status.
priority: 0
template: implicit
type: listtemplate: implicit means the expression is treated as Jinja without
needing {{ }} around it, exactly like when:.
Deriving the answer from evidence
There are three sources of truth available to you, in rough order of reliability.
From the exit code
Many tools distinguish “I did something” from “there was nothing to do” by exit code. Where they do, that is the cleanest signal:
- name: Regenerate the CRL if the tool says it is stale
ansible.builtin.command: /usr/local/bin/crl-refresh
register: crl
changed_when: crl.rc == 1 # 0 = already current, 1 = regenerated
failed_when: crl.rc not in [0, 1]
Note that changed_when and failed_when are both needed here. Once
you tell Ansible that rc == 1 means “changed”, you must also tell it
that rc == 1 is not a failure. Measured on 2.21.3, omitting
failed_when gives a result that is simultaneously changed and failed:
$ ansible-playbook -i inv2.ini edge.ymlfatal: [localhost]: FAILED! => {"changed": true, "changed_when_result": true, "cmd": ["/bin/false"], "msg": "The command exited with a non-zero return code.", "rc": 1, "stdout": ""}The result carries "changed": true and "changed_when_result": true
alongside FAILED!. The change was reported and the task failed, which
means the host is removed from the play. The next lesson covers
failed_when properly.
From the output
When the exit code is uninformative, the tool’s own words often are:
- name: Apply pending schema migrations
ansible.builtin.command: /opt/app/bin/migrate --apply
register: migrate
changed_when: "'No migrations to apply' not in migrate.stdout"
This is more fragile than the exit code, because it depends on a string the vendor may reword in the next release. Where you use it, say so in a comment, and prefer a substring that is unlikely to be cosmetic.
From a separate probe
The most robust pattern, and the one that generalises. Run a read-only query, decide from its result, and let the action task be skipped entirely when there is nothing to do:
- name: Read the currently applied licence state
ansible.builtin.command: /opt/vendor/bin/licctl status
register: lic
changed_when: false # a status query changes nothing
- name: Apply the licence
ansible.builtin.command: /opt/vendor/bin/licctl apply --key REPLACE_ME
when: "'ACTIVE' not in lic.stdout"
On a converged host the second task is skipped, so it cannot report
changed at all. On a host that needs it, the task runs and reports
changed honestly with no changed_when needed.
This is the pattern to reach for first. It restores the full read-compare-act cycle rather than patching the report.
Measured on 2.21.3, with the same command fenced two ways:
$ ansible-playbook -i inv2.ini creates.yml -vTASK [One-shot step, marker absent] ********************************************
changed: [localhost] => {"changed": true, "cmd": ["/bin/echo", "running-the-one-shot-step"], "rc": 0}
TASK [One-shot step, marker present] *******************************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/echo", "would-have-run"], "msg": "Did not run command since '/etc/hostname' exists", "rc": 0}"changed": false with "msg": "Did not run command since ... exists".
The module explains itself, which is more than a bare
changed_when: false ever will.
The line that separates the tool from the anti-pattern
Both of these are one line. They are not the same line.
# Accurate: a status query genuinely changes nothing
- name: Read the cluster health
ansible.builtin.command: /usr/bin/clusterctl health
register: health
changed_when: false
# Silencing: this task really does change the host
- name: Restart the application
ansible.builtin.command: systemctl restart myapp
changed_when: false # added because the run was "noisy"
The first states a fact: the command is a read. The second states a falsehood, and it was added for a reason that sounds reasonable — the recap was noisy and this made it quiet.
The test is one question, and it is not “is the report noisy”:
Does this command alter the host when it runs?
If yes, changed_when: false is a lie, and everything downstream of
changed is now working from bad data. If no, it is documentation.
A list of conditions is joined with and
The keyword’s type is list, and a list is evaluated as a conjunction.
All conditions must be true for the task to report changed.
$ ansible-playbook -i inv2.ini semantics.ymlTASK [A list of conditions is joined with AND] *********************************
ok: [localhost]
TASK [One string with an explicit or] ******************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The two tasks ran the identical command. The first:
changed_when:
- a.rc == 0 # true
- "'y' in a.stdout" # false -> and -> ok
The second:
changed_when: b.rc == 0 or 'y' in b.stdout # true -> changed
When you mean “any of these”, you must write or inside a single
string. A list will silently give you and, and the failure is quiet: a
task that should have reported changed reports ok, no handler fires,
and nothing in the output suggests a problem.
Knowledge check
Knowledge check · 4 questions
Q1. A command task carries changed_when as a list of two conditions, one true and one false. What does the task report?
Q2. Which of these is the correct use of changed_when: false?
Q3. Which patterns produce accurate change reporting for a command that has no declarative module? Select all that apply.
Q4. Once a task uses changed_when to treat exit code 1 as a change, no further keyword is needed for the task to succeed.
Passing score: 75%. Answers are checked in this browser.