Ansible Role Idempotence: Why a changed=0 Run Is the Only Proof You Have
A playbook that reports changes every run has quietly stopped being a drift detector.
The problem
Ansible’s whole value proposition rests on one promise: run the playbook again, and if nothing needed to change, nothing will report as changed. That is what lets you run a role against a thousand hosts and read the summary instead of the diff.
It is easy to write a role that breaks this promise while still working, in the sense that it converges the host to the right state every time. Here is a task that does exactly that:
# Broken. Reports changed on every single run.
- name: Render the application config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/myapp/app.conf
- name: Restart the application
ansible.builtin.command: systemctl restart myapp
{# app.conf.j2 #}
# Rendered {{ ansible_date_time.iso8601 }}
listen_port = {{ app_port }}
The config content is correct on every run. The service ends up in the right state on
every run. And yet ansible-playbook site.yml reports two changed tasks every single
time, because the template’s rendered content is never byte-identical to what is already
on disk — the timestamp comment guarantees a diff — and command has no concept of state
at all; it reports changed unconditionally unless you tell it otherwise.
Nobody notices, because the run still exits 0. changed=2 looks like normal, healthy
convergence work. The failure is silent in the worst way: the one signal that would tell
you “something actually changed on this host between last run and this one” has been
permanently pegged to “something changed,” so it has stopped carrying information. A real
config drift — someone hand-editing /etc/myapp/app.conf on the box — produces exactly
the same changed=1 as a no-op run. You cannot tell them apart from the CLI output, which
is the only thing most people look at.
This matters more as a role gets used for drift detection or --check gating in CI, and
it matters most exactly when you need it: during an incident, when you want to know
whether Ansible actually did something on the last run or whether the box was already
fine.
Working through it
Command and shell are not state modules
ansible.builtin.command and ansible.builtin.shell have no idea what “state” means for
the thing they run. They report changed: true on every successful invocation, always,
because that is the only safe default when the module cannot inspect the system itself.
The fix is changed_when, which lets you tell Ansible how to interpret the result:
- name: Restart the application only if the config actually changed
ansible.builtin.command: systemctl restart myapp
when: render_config.changed
changed_when: true
That is not idempotence by itself — it is conditional execution wired to the real
signal, which is whether the preceding task changed anything. The task now only runs, and
only reports changed, when there was a genuine reason to restart. changed_when: false is
also common, for read-only commands you run purely to gather facts:
- name: Check whether the service is enabled
ansible.builtin.command: systemctl is-enabled myapp
register: enabled_check
changed_when: false
failed_when: false
Templates fail idempotence when the content is not deterministic
A template task is idempotent by construction, as long as the rendered output is a pure
function of your variables. The moment you put something time-dependent, host-dependent in
an undesired way, or randomly generated into the template, that guarantee is gone.
in a comment is the classic version. So is embedding
when you meant to embed a group variable that should be the same
everywhere, or a Jinja filter that depends on dictionary ordering that is not guaranteed
stable across Python versions. The fix is boring: keep templates a pure function of
variables that themselves do not change between runs, and if you need a “last rendered”
timestamp for humans, put it in a separate log line, not in the file whose content you
are diffing against.
Package and service modules are usually fine, until you loosen the version pin
ansible.builtin.package with state: present and no version is stable — it does nothing
if any version is installed. The moment you pin a version, upstream repositories can
change under you, and a role that used to be a no-op starts reporting changed because the
available version moved and your state: latest or unpinned spec resolved differently.
This is not a bug in the module; it is a role that promised idempotence it cannot actually
deliver, because “the newest version available today” is not a stable target.
Idempotence is not something you inspect, it is something you assert
Looking at changed=0 in a terminal after a manual run proves nothing about the next
run, on the next host, after the next change. It has to be a check that fails the build,
not a habit. Molecule’s idempotence sequence does exactly this: it runs converge
twice against the same instance and fails the test if the second run reports any changed
or failed tasks.
The solution
A minimal role plus a Molecule scenario that makes idempotence a CI gate rather than something you remember to check.
# roles/myapp/defaults/main.yml
app_port: 8080
{# roles/myapp/templates/app.conf.j2 #}
# Managed by Ansible. Local edits are overwritten.
listen_port = {{ app_port }}
# roles/myapp/tasks/main.yml
- name: Render the application config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/myapp/app.conf
owner: root
group: root
mode: "0644"
register: render_config
- name: Restart the application only if the config changed
ansible.builtin.systemd:
name: myapp
state: restarted
when: render_config.changed
ansible.builtin.systemd with state: restarted still reports changed every time it
restarts — that part is correct, a restart is a real change. What makes the role
idempotent overall is that it only restarts when the config actually differs, guarded by
when: render_config.changed rather than running unconditionally.
# molecule/default/molecule.yml
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: myapp-instance
image: geerlingguy/docker-debian12-ansible:latest
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
# molecule/default/converge.yml
- name: Converge
hosts: all
become: true
vars:
app_port: 8080
roles:
- role: myapp
# molecule/default/verify.yml
- name: Verify
hosts: all
become: true
tasks:
- name: Check the rendered config content
ansible.builtin.command: cat /etc/myapp/app.conf
register: config_contents
changed_when: false
- name: Assert the port is present
ansible.builtin.assert:
that:
- "'listen_port = 8080' in config_contents.stdout"
Running it:
pip install "molecule[docker]" molecule-plugins[docker] ansible
molecule test
molecule test runs, in order: create, converge, idempotence, verify, destroy.
The idempotence step is converge run a second time against the same container, and it
fails the whole test if that second run reports anything other than ok. Correct output
for the second converge looks like this:
PLAY RECAP *********************************************************
myapp-instance : ok=2 changed=0 unreachable=0 failed=0 skipped=0
If instead you see changed=1 on that second pass, Molecule reports Idempotence test
failed because of the following tasks: and names them — which is exactly the diagnostic
you do not get from a manual ansible-playbook run in production.
Conclusion
changed_when is not an optimisation, it is the module telling the truth. Every
command or shell task without one is a task that cannot participate in drift
detection, because it has no way to say “nothing needed doing.”
Idempotence has to be tested against the same target twice, in the same run of CI.
A role that looks idempotent because you happened to run it against an already-converged
host once is not tested; it is observed. Molecule’s idempotence step, or a hand-rolled
equivalent that runs the playbook twice and greps for changed=0, is the actual check.
Non-idempotent building blocks are fine as long as they are guarded, not banned. A
service restart is inherently a change every time it happens; the requirement is that it
only happens when something upstream genuinely changed, wired through register and
when, not that every module in the role reports changed: false in isolation.