AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting
Describing state instead of steps
What you'll learn
- State the difference between a task that describes a step and one that describes a state
- Explain why re-evaluating reality is what makes a rerun safe
- Identify shell and command tasks as imperative code inside a declarative file
- Recognise where a declarative module does not exist and what to do about it
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
The Linux course’s linux-desired-state-and-idempotency introduces this
distinction and states the rule: declare state, not steps. That lesson
gives you the idea. This part owns the consequences — the result
vocabulary, the precision tools that correct it, and what breaks when
the reporting is wrong — and it starts by making the distinction sharper
than “declarative is better”.
Two tasks that produce the same outcome
An imperative instruction names an action:
useradd --create-home --shell /bin/bash deploy
A declarative instruction names a condition:
- name: Ensure the deploy account exists
ansible.builtin.user:
name: deploy
shell: /bin/bash
create_home: true
state: present
On a machine with no deploy user, both produce the same result. The
difference appears on the second run, and it is not a difference of
degree.
The useradd command replays its action. The account exists, so
useradd exits non-zero with user 'deploy' already exists. It has
made no attempt to reconcile anything; it was told to perform an action
and it performed it, and the world’s objection is the world’s problem.
The user module does something categorically different. It reads the
current state of the account, compares it with the declared state,
finds no difference, and reports ok without touching anything.
That comparison step is the whole idea. A declarative task is not “a command with better error handling” — it is a read, compare, act cycle in which the action is conditional on the comparison.
The re-evaluation is what makes a rerun safe
Say the third run happens after someone has changed the deploy user’s
shell to /usr/sbin/nologin by hand.
The imperative script still fails with “already exists”, and the shell stays wrong. The state it was meant to produce has drifted and the script has no vocabulary for noticing.
The declarative task reads the account, finds shell differs from
/bin/bash, changes only that attribute, and reports changed. The
host converges back to the declaration, and the report says so.
This is the property that makes automation safe to run repeatedly, and it depends on the module doing real work:
- Read the current state from the host.
- Compare it with the declared state.
- Act only on the difference.
- Report whether it acted.
Skip step 1 and you have an imperative script. Skip step 4 and you have a declarative task that lies, which is the subject of the rest of this part.
shell and command are imperative code inside a declarative file
- name: Add the deploy user
ansible.builtin.command: useradd --create-home --shell /bin/bash deploy
This is a YAML task, in a playbook, using an Ansible module. It is imperative in every respect that matters:
- It performs an action rather than declaring a condition.
- It does not read current state, so it cannot compare.
- It fails on the second run, exactly as the shell script does.
- It reports
changedregardless of what actually happened.
That last point is worth stating precisely: command and shell
always report changed when the command runs. They have no way not
to. Ansible transferred a command, the command ran, and Ansible has no
idea whether it altered the host — so it reports the safe assumption.
$ ansible-playbook -i inv2.ini vocab.ymlTASK [A module that inspected nothing and reports changed anyway] **************
changed: [localhost]
TASK [The same command, told the truth about itself] ***************************
ok: [localhost]The two tasks ran the same command. The second carries
changed_when: false, which is how the author supplied the knowledge
the module could not obtain. Lesson 4 of this part is entirely about
doing that correctly rather than reflexively.
When there is no declarative module
Sometimes there genuinely is not one. A vendor appliance with only a CLI, a bespoke internal tool, a one-off migration.
The answer is not to pretend. It is to supply, by hand, the parts of the read-compare-act cycle that the module cannot do for itself:
- name: Check whether the licence is already applied
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"
Task one is the read. Its when:-less, changed_when: false shape
says “this is a probe, it alters nothing”. Task two is the compare and
act: it runs only when the probe found the licence missing, so on a
converged host it is skipped and reports nothing.
The result is a pair of tasks that behave declaratively even though
neither module does. On a converged host: ok=1 changed=0 skipped=1. On
an unlicensed host: ok=1 changed=1.
That pattern — probe, then act conditionally on the probe — is the general answer, and the rest of this part refines it.
Knowledge check
Knowledge check · 4 questions
Q1. What makes a task declarative?
Q2. Why does ansible.builtin.command always report changed when the command runs?
Q3. A play has fourteen declarative tasks and one shell task. What does the shell task cost you? Select all that apply.
Q4. A probe task with changed_when: false followed by an action task guarded by when: on the probe result gives you declarative behaviour from two non-declarative modules.
Passing score: 75%. Answers are checked in this browser.