AnsibleIX · command, shell and rawcommand, shell and raw
Making an unavoidable shell task idempotent
What you'll learn
- Explain why an inaccurate changed result breaks handlers and audit trails
- Use creates and removes as the first and cheapest guard
- Build a read-only probe task and drive the work from a when condition
- Derive changed_when from a command output correctly
- Recognise blanket changed_when: false as a reporting change, not a fix
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
Sometimes there is no module. A vendor’s installer, a licence activation, a database migration tool, a proprietary CLI that manages its own state — occasionally the honest answer is that the work has to be done by running a program.
That is a legitimate position. What is not legitimate is leaving the
task reporting changed on every run, because that result is consumed
by machinery that assumes it is true.
Why an inaccurate changed is expensive
A changed result is not decoration. Three separate systems read it:
- Handlers.
notifyfires onchanged. A read-only probe written as acommandtask will restart the service it was probing, every run, forever. On a fleet withserialbatching, that is a rolling restart nobody asked for. - The recap.
changed=0on a converged fleet is the single most useful number in Ansible. It is how an operator knows a run did nothing, which is how they know the estate matches its definition. A play with six always-changed tasks reportschanged=6on a fleet that is perfectly converged, and the number stops meaning anything. - The audit trail. “What changed on these servers last Tuesday” is a question somebody will ask you. If the answer is “the log says everything changed, every night, for two years”, you cannot answer it.
Technique 1: creates and removes
The best fix is the one where the work does not happen at all.
creates names a path; if it exists, the module exits without running.
removes is the inverse: if the path does not exist, the module
exits without running. Both are evaluated by the module on the managed
node, and both work under --check, which is the only check-mode
support command and shell have.
- name: Run the vendor installer
ansible.builtin.command:
cmd: /opt/vendor/install.sh --unattended
creates: /opt/vendor/.installed
- name: Extract the release archive
ansible.builtin.command:
cmd: /usr/bin/tar -xzf /tmp/app-1.4.2.tgz -C /opt/app
creates: /opt/app/releases/1.4.2/VERSION$ ansible-playbook -i localhost, idem.yml -vTASK [Technique 1 - creates guard, work already done] **************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/echo", "would-have-run"],
"msg": "Did not run command since '/etc/hostname' exists",
"rc": 0, "stdout": "skipped, since /etc/hostname exists"}The judgement call is which path to name. Two rules that hold up:
- Name something the work itself produces. A version-stamped directory, a generated config, a binary. Not a marker file you touch yourself in a following task — that decouples the guard from the work, and the first failed run leaves you with a marker and no installation.
- Name something version-specific when the command is version-
specific.
creates: /opt/app/currentnever runs again after 1.4.2, including for 1.5.0.creates: /opt/app/releases/{{ app_version }}/VERSIONruns once per version, which is what you meant.
Technique 2: a read-only probe and a when
When there is no file whose existence answers the question, ask the question explicitly in a separate task — one that reads state and changes nothing — and drive the work from its result.
- name: Read the currently applied schema version
ansible.builtin.command: /usr/local/bin/appmigrate --current-version
register: schema_probe
changed_when: false # correct here: this task genuinely changes nothing
- name: Apply outstanding migrations
ansible.builtin.command: /usr/local/bin/appmigrate --apply
when: schema_probe.stdout | trim != app_schema_version
notify: Restart app$ ansible-playbook -i localhost, idem.ymlTASK [Technique 2a - read-only probe] ******************************************
ok: [localhost]
TASK [Technique 2b - do the work only when the probe says so] ******************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=1Three properties make this the technique to reach for when creates
does not fit:
- The probe is honest about itself.
changed_when: falseon a task that provably reads and does not write is a true statement, not a suppression. That distinction is the whole point of the last section of this lesson. - The decision is visible in the output. A skipped task with
false_conditionin the verbose result tells the next engineer exactly why nothing happened. - The work task can notify a handler truthfully, because it only runs when there is something to do.
Where a purpose-built read-only module exists, use it instead of a
command probe. ansible.builtin.stat is the common one:
- name: Look for the certificate the renewal would replace
ansible.builtin.stat:
path: /etc/pki/tls/certs/app.pem
register: cert
- name: Renew the certificate
ansible.builtin.command:
cmd: /usr/bin/certtool --renew --out /etc/pki/tls/certs/app.pem
when: not cert.stat.exists or (cert.stat.mtime | int) < (renew_before_epoch | int)Technique 3: changed_when from the command’s own output
Last resort, and still a real technique: run the command, then decide from what it said.
- name: Converge the firewall ruleset
ansible.builtin.command: /usr/sbin/fwctl apply --config /etc/fw/rules.conf
register: fw_apply
changed_when: "'no changes required' not in fw_apply.stdout"
notify: Reload firewall
- name: Apply outstanding database migrations
ansible.builtin.command: /usr/local/bin/appmigrate --apply
register: migrate
changed_when: migrate.stdout is not search('0 migrations applied')$ ansible-playbook -i localhost, idem.yml -vTASK [Technique 3 - changed_when derived from the command output] **************
ok: [localhost] => {"changed": false, "changed_when_result": false,
"cmd": ["/bin/echo", "nothing to do"], "rc": 0,
"msg": "", "stdout": "nothing to do"}Two conditions have to hold before this is honest:
- The command must be safe to run every time.
changed_whenis evaluated after execution. It changes the report, never the behaviour. If running the command twice is harmful, this technique does not apply and you need technique 1 or 2. - The output must be a reliable signal. A string match against a
message that a vendor can reword in a point release is a guard with a
maintenance cost. Prefer a documented exit code, a structured output
mode (
--format jsonpiped throughfrom_json), or a counter.
The thing this is not
Choosing between them
| Situation | Technique |
|---|---|
| The work produces a file or directory | creates |
| The work removes something | removes |
| State is readable but is not a file | Probe task plus when |
| The command reports what it did, and is safe to rerun | changed_when from output |
| The command is not safe to rerun and state is unreadable | Redesign. There is no reporting fix for this. |
Blast radius
An always-changed task is a blast-radius problem in itself, because it
propagates. On a play with serial: 10 and a notify: Restart app, one
dishonest probe task restarts the service on every host in the fleet, in
batches, every night — an outage schedule created by a reporting bug.
Before trusting a play as converged, run it twice and read the second recap:
ansible-playbook -i inventories/staging site.yml --limit staging-web01.example.com
ansible-playbook -i inventories/staging site.yml --limit staging-web01.example.comAny task reporting changed in the second run is either a genuine drift
source or a task that needs one of the three techniques above. There is
no third explanation.
Knowledge check
Knowledge check · 4 questions
Q1. A task runs a vendor installer and reports changed every run, notifying a handler that restarts the application. Which fix is correct?
Q2. On which tasks is changed_when: false a truthful statement rather than a suppression? Select all that apply.
Q3. changed_when is evaluated before the command runs, so a false result prevents execution.
Q4. A play deploys version 1.5.0 but the extract task reports ok and no files appear. The task carries creates: /opt/app/current. What is the likely cause?
Passing score: 75%. Answers are checked in this browser.