skip to content

In Ansible, what does a module reporting "changed" actually mean, and why do the command and shell modules report changed on every run?

level: middleimportance: must knowfreq 72%

answer

  1. the second run should be all ok
  2. modules compare before they act
  3. an opaque command reveals nothing
  4. creates and removes skip the task
  5. changed_when false for read-only commands

basics

~20 s

Changed means the module actually altered the host during this run. The command and shell modules run an opaque command and cannot tell whether anything changed, so they report changed every time unless you add creates, removes, or changed_when.

solid answer

~50 s

Every Ansible module is expected to honour an idempotency contract: inspect the current state of the target, compare it with the state the task declares, act only if they differ, and report `changed: true` only when it actually acted. `ansible.builtin.file`, `copy`, `template`, `package`, `service` and `user` all do this, which is why a second run of a correct playbook is all `ok`. `command` and `shell` execute an arbitrary program whose effect Ansible cannot inspect, so they conservatively report changed whenever the command exits successfully. The fixes, in order of preference: use a real module instead; if you must shell out, give the task a `creates` or `removes` path so Ansible skips it when the artifact already exists; and for read-only commands set `changed_when: false`. Noisy changed output matters because handlers fire on notify and because "is this run clean?" stops meaning anything.

code

yaml · 10 lines
yaml
- name: Extract the release tarball only once
  ansible.builtin.command:
    cmd: tar -xzf /tmp/release.tar.gz -C /opt/app
    creates: /opt/app/VERSION

- name: Report the running kernel without ever reporting changed
  ansible.builtin.command:
    cmd: uname -r
  register: kernel
  changed_when: false

go deeper

for a junior

Know that a correct playbook's second run should be all ok, and that command and shell are the tasks that spoil that. Be able to name creates as the guard you add to a one-shot command.

for a middle

Explain the module contract as inspect, compare, act, report, and why an opaque command breaks it. Walk through the remedies in order: a real module first, then creates or removes, then changed_when.

for a senior

Show the operational cost: noisy changed output means handlers restart services on every run and nobody reads the recap. Be ready to describe auditing an inherited playbook and converting its shell tasks to real modules.

for a principal

Own the standard: whether the estate is expected to converge cleanly on a schedule, whether a clean run is a release gate, and how much shell-out you tolerate before an operation must become a supported module.

## What "changed" is a promise about Ansible's execution model is a sequence of tasks, each dispatching one module to the managed host. The module's job is not "run this action" but "make this statement true". A well-behaved module therefore does four things in order: gather the current state of the thing it manages, compare that with the parameters the task declared, take action only if they differ, and return JSON that includes a `changed` boolean reflecting whether it acted. That boolean is load-bearing in three places. It drives the per-host recap counters (`ok`, `changed`, `failed`), it decides whether a `notify` fires its handler, and it is the signal humans use to answer "did this run do anything to production?". A run that is entirely `ok` is the proof that the hosts already match the playbook — Ansible's version of convergence. ```yaml - name: Ensure nginx is installed ansible.builtin.package: name: nginx state: present ``` Run this on a host that already has nginx and the package module queries the package database, sees no work to do, and returns `changed: false`. That is the contract being honoured. ## Why command and shell cannot honour it `ansible.builtin.command` and `ansible.builtin.shell` take a command line and execute it. Ansible has no model of what `/usr/local/bin/bootstrap.sh` or `sed -i ...` does, no way to inspect the state before, and no way to diff it after. A non-zero exit is a failure; a zero exit could mean "I did the work" or "there was nothing to do" and the module cannot distinguish them. It therefore reports `changed: true` on every successful run — the conservative choice, since under-reporting a change would silently suppress handlers. The same applies to `ansible.builtin.raw` (which does not even require Python on the target) and to any module you write badly. It is not a bug in command; it is the absence of information. ## Constraining a shell-out In preference order: **1. Use the purpose-built module.** Most shelling-out is a habit, not a necessity. `sed -i` on a config file is `ansible.builtin.lineinfile` or, better, a whole-file `ansible.builtin.template`. `curl` against an API is `ansible.builtin.uri`. `git clone` is `ansible.builtin.git`. `systemctl enable --now` is `ansible.builtin.service` or `ansible.builtin.systemd_service`. These modules already do the state comparison for you and support check mode. **2. Guard with `creates` or `removes`.** Both `command` and `shell` accept a `creates` path (skip the task if that path already exists) and a `removes` path (skip if it does not exist). The task then reports `skipped` on subsequent runs. This makes the task idempotent at the task level even though the command itself is not — it is the standard pattern for one-shot installers and migrations that drop a marker file. ```yaml - name: Bootstrap the application once ansible.builtin.command: cmd: /usr/local/bin/bootstrap.sh creates: /var/lib/app/.bootstrapped ``` **3. Set `changed_when` explicitly.** For a command that only reads (`ansible.builtin.command: /usr/bin/systemctl is-active nginx`), `changed_when: false` tells Ansible the task never changes anything. For a command whose output reveals whether it acted, register the result and derive `changed_when` from it. This is the escape hatch when `creates` does not fit. A fourth, occasionally correct answer: if the operation is repeated across many roles and deserves a real state comparison, promote it into a custom module. ## Check mode makes the cost visible Running with `--check` asks every module to predict its change without making it. Modules that implement check mode return the `changed` they would have produced. `command` and `shell` skip entirely in check mode by default, because running an arbitrary command is exactly what check mode promised not to do. So a playbook heavy in shell tasks produces a `--check` run that reports almost nothing and predicts almost nothing — the dry run stops being a dry run and becomes a blank page. Setting `check_mode: false` on a genuinely read-only command task is how you get such a task to run during a check. ## Why interviewers care A playbook full of always-changed tasks is one you cannot run continuously, cannot diff, and cannot trust. Handlers fire on every run, so services restart during an unrelated deploy. The recap is always noisy, so nobody reads it, so a real change hides in the noise. And the playbook stops being a description of desired state and turns back into a shell script written in YAML — which is precisely the thing configuration management exists to replace. A note on naming: the fully-qualified names shown here (`ansible.builtin.command`) are the collection-era spelling introduced with ansible-core 2.10; the short names `command` and `shell` still resolve and appear in older playbooks.

  • If a task must shell out and there is no marker file to use with creates, how do you keep it honest?
    Register the command's result and derive `changed_when` from it — for example, treat a known "nothing to do" string in `stdout` or a specific return code as unchanged. If the command only reads, `changed_when: false` is the whole answer. If neither works, that is a strong signal the operation deserves a real module that can compare state itself.
  • What happens to handlers in a playbook where several tasks always report changed?
    They fire on every run. A `notify` is evaluated against the task's `changed` result, so an always-changed task means its handler — typically a service restart — executes on every play, even when nothing about the service's configuration moved. That turns a no-op convergence run into an unplanned restart, which is how a routine playbook run causes a production blip.
  • Does skipping a task with creates count as changed?
    No. When the `creates` path exists the task is skipped entirely: the command never runs, the result is `skipped`, `changed` is false, and any `notify` on that task does not fire. That is the point — it converts a one-shot command into something safe to leave in a playbook that runs repeatedly.

saying these in an interview costs you the question

  • Says changed just means the task ran successfully
  • Claims command is idempotent because the script is
  • Adds changed_when false to a task that mutates state
  • Thinks --check makes shell tasks safe to run
  • Ignores that always-changed tasks restart services via handlers

context