Why does an Ansible task using the command or shell module report "changed" on every run, and what do you use to make it report honestly?
answer
- Ansible cannot see inside a command
- safe default is always changed
- report versus actually running
- changed_when false for read-only queries
- creates skips the command entirely
basics
~20 sAnsible cannot inspect what an arbitrary command did, so command and shell report changed whenever they run. Fix it with changed_when, evaluating the return code or output, with changed_when false for read-only commands, or with creates so the task is skipped entirely.
solid answer
~40 sModules like `ansible.builtin.file` or `ansible.builtin.package` inspect the system, decide whether anything needs doing, and return `changed` accordingly. `command` and `shell` run an opaque program — Ansible has no idea whether `/usr/local/bin/deploy.sh` mutated anything — so they report `changed` on every execution. That single dishonest result breaks the run report and, worse, fires every handler notified by the task. Three fixes: `changed_when: false` for a purely read-only command; `changed_when: "'updated' in result.stdout"` (with `register: result`) when the command's own output or `rc` tells you whether it did work; or the module's `creates` / `removes` arguments, which make Ansible skip the command entirely when the target path already exists. `creates` is the strongest because the command is not run at all, so it is genuinely idempotent rather than merely honest about its result.
code
yaml · 14 lines- name: Read cluster status (read-only, never a change)
ansible.builtin.command: /usr/local/bin/cluster-ctl status
register: cluster
changed_when: false
- name: Apply migrations (changed only when it did work)
ansible.builtin.command: /usr/local/bin/migrate --apply
register: migrate
changed_when: "'no pending migrations' not in migrate.stdout"
- name: Unpack the bundle exactly once
ansible.builtin.command: /usr/bin/unpack-vendor.sh /opt/vendor
args:
creates: /opt/vendor/.unpackedgo deeper
Know that command and shell always report changed, and that read-only commands should carry changed_when: false. Be able to say why a purpose-built module is preferred over shelling out.
Explain the mechanism: Ansible cannot inspect an arbitrary program, so it defaults to changed, and changed_when, failed_when and the creates/removes arguments are how you restore an honest status.
Connect the false status to production consequences — every handler notified by such a task fires on every run — and choose between rewriting the report and making the side effect itself conditional with creates.
Own the standard: how a team keeps shell-outs rare and reviewable, whether a non-zero changed count in a steady-state run is treated as a build failure, and what that convention buys in drift visibility across the fleet.
## The idempotency contract Every Ansible task returns a result status: `ok`, `changed`, `skipped`, `failed`, or unreachable. The status is not cosmetic. It drives the play recap, it drives handler notification, and it is the basis of the claim that a playbook can be run repeatedly with no further effect. Most modules honour that contract by checking current state first: `ansible.builtin.file` stats the path, `ansible.builtin.service` queries the service manager, `ansible.builtin.copy` compares checksums. If reality already matches, the module returns `ok` and touches nothing. ## Why command and shell cannot `ansible.builtin.command` and `ansible.builtin.shell` run an arbitrary program. Ansible has no model of what that program does. It cannot tell that `mysql -e 'CREATE DATABASE IF NOT EXISTS app'` was a no-op, or that a deploy script exited early. Faced with no information, the modules take the safe default and report **changed** every time they execute. (`ansible.builtin.raw` behaves the same way.) The visible symptoms in a real repository: - The play recap shows `changed=7` on a run where nothing happened, so the recap stops being a signal that anything is wrong. - A handler notified by such a task restarts a production service on every single run. - Check mode is useless for that task: by default `command` is simply skipped under `--check`, so you get no preview at all. ## Fix 1: changed_when `changed_when` overrides how the task's changed status is computed. It takes a raw Jinja expression, exactly like `when`. ```yaml - name: Read the current cluster leader ansible.builtin.command: /usr/local/bin/cluster-ctl leader register: leader changed_when: false ``` A read-only query gets `changed_when: false` — the command runs, but it never claims a change and never fires a handler. When the command may or may not do work, derive the answer from what it told you: ```yaml - name: Apply pending migrations ansible.builtin.command: /usr/local/bin/migrate --apply register: migrate changed_when: "'no pending migrations' not in migrate.stdout" ``` A return-code convention works the same way: `changed_when: migrate.rc == 2`. Note that `changed_when` only rewrites the *report*; the command still ran. It also pairs with `failed_when`, which overrides failure the same way — a command that exits non-zero for an expected condition needs `failed_when` so it is not treated as an error. ## Fix 2: creates and removes `creates` and `removes` are arguments of the `command` and `shell` modules themselves, not general task keywords. If the path in `creates` already exists, the module does not run the command at all and the task reports **skipped**; `removes` is the mirror image — run only if the path exists. ```yaml - name: Unpack the vendor bundle once ansible.builtin.command: /usr/bin/unpack-vendor.sh /opt/vendor args: creates: /opt/vendor/.unpacked ``` This is stronger than `changed_when`, because the side effect itself is now conditional rather than just the label on it. Where the command has a natural marker file or produced artefact, prefer it. ## Fix 3: do not use command at all The best fix is often to delete the task. `command: systemctl restart nginx` should be the `service` module; `shell: 'echo x >> /etc/hosts'` should be `lineinfile`; a `curl` should be `uri`. Reaching for `shell` when a purpose-built module exists is the single most common reason a playbook is not idempotent, and interviewers ask this question specifically to see whether you reach for the module first. ## When shell instead of command `command` does not run through a shell: no pipes, no redirection, no globbing, no environment-variable expansion. `shell` does. Use `command` unless you genuinely need shell features, because passing untrusted variables into `shell` is a command-injection surface. If you must, quote with the `quote` filter. ## The judgment an interviewer is listening for A weak answer says "add changed_when: false" and stops — that hides the noise and can hide a real change. A strong answer separates three cases: the command reads (report false), the command may act and tells you (derive from stdout or rc), the command acts once (`creates`, or move the conditional into the task). And it names the consequence that makes this more than cosmetics: a mis-reported change is what turns a config-management run into an unwanted production restart.
- What is the difference between changed_when: false and using the creates argument?`changed_when: false` still runs the command every time and only rewrites the reported status — appropriate for read-only queries. `creates` prevents the command from running at all when the named path exists, so the task reports skipped and the side effect genuinely happens once. Use `creates` when the command mutates something and has a natural marker; use `changed_when: false` when it only reads.
- A command exits with return code 1 for a condition you consider normal. How do you keep the play going without hiding real errors?Set `failed_when` to the condition that is genuinely a failure — for example `failed_when: result.rc not in [0, 1]`, with `register: result`. That is narrower than `ignore_errors: true`, which swallows every failure including the ones you did want to hear about, and it keeps the task's reported status meaningful.
- Why is command generally preferred over shell?`command` does not invoke a shell, so pipes, redirection, globbing and variable expansion are not available — and neither is shell injection through an interpolated variable. `shell` should be reserved for cases that actually need those features; interpolating untrusted input into it is a real command-injection risk.
saying these in an interview costs you the question
- Claims command is idempotent because the script is idempotent
- Puts changed_when: false on every command task to silence the recap
- Thinks creates makes the task report changed rather than skipped
- Uses ignore_errors where failed_when would express the real condition
- Reaches for shell when a purpose-built module exists