Skip to main content
RunBook Academy

AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting

changed_when: telling the truth about a command

Intermediate⏱ ~18 minansible-playbook

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

Not yet marked complete on this device.

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.

Read-only / Safewhat the keyword accepts
$ ansible-doc -t keyword changed_when
changed_when:
applies_to:
- Task
- Handler
description: Conditional expression that overrides the task's normal 'changed' status.
priority: 0
template: implicit
type: list

template: 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:

Read-only / Safechanged_when alone does not make a non-zero exit acceptable
$ ansible-playbook -i inv2.ini edge.yml
fatal: [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:

Read-only / Safethe creates fence doing the module's job
$ ansible-playbook -i inv2.ini creates.yml -v
TASK [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.

Read-only / Safelist semantics, measured
$ ansible-playbook -i inv2.ini semantics.yml
TASK [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=0

The 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

  1. Q1. A command task carries changed_when as a list of two conditions, one true and one false. What does the task report?

  2. Q2. Which of these is the correct use of changed_when: false?

  3. Q3. Which patterns produce accurate change reporting for a command that has no declarative module? Select all that apply.

  4. 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.