Ansible Idempotency in Practice

Run it again. If anything changed, you have a problem. What idempotency actually means when you apply a playbook to a live system — and the specific places it breaks without warning.
The playbook ran. Everything went yellow — changed. I ran it again. Everything went yellow again.
That is the problem idempotency is supposed to solve. And it means something went wrong in the design, not the execution.
What idempotency actually means
The definition is simple: run the same operation twice, get the same result. The second run should report ok across the board — no changes, because the system is already in the desired state.
In practice, idempotency is not a property Ansible gives you automatically. It is something you have to design for, task by task. Some modules are idempotent by nature. Others are not, and they will silently lie to you.
The modules that do it right
Most of the core Ansible modules are built around state, not commands. They check whether the desired state already exists before doing anything.
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
Run this twice against a system that already has Nginx installed. First run: changed. Second run: ok. The module checks the package state, finds it already present, and does nothing. That is the behaviour you want everywhere.
The same applies to copy, template, file, service, user, and most built-in modules. They converge on state. They do not repeat operations.
The module that does not: command and shell
This is where most idempotency problems start.
- name: Initialise the database
ansible.builtin.command: mysqladmin -u root create epicbook
Run this once: works. Run it again: fails — or worse, on a different system, appears to succeed and reports changed when the database already exists. The command and shell modules have no concept of state. They run what you tell them to run. Every time.
Ansible has two escape hatches for this.
The first is creates — tell the module to skip execution if a specific file already exists:
- name: Run one-time setup script
ansible.builtin.command: /opt/app/setup.sh
args:
creates: /opt/app/.setup-complete
The script creates the sentinel file on first run. On every subsequent run, Ansible sees the file, skips the task, and reports ok. You are manually implementing the state check the module cannot do itself.
The second is changed_when: false — for tasks that query rather than modify, where you never want a change reported:
- name: Check application version
ansible.builtin.command: /opt/app/bin/app --version
register: app_version
changed_when: false
Neither of these is a perfect solution. They are patches on a module that was not designed for idempotency. The better answer is to avoid command and shell unless there is no built-in module for the job.
Where idempotency breaks quietly: handlers
Handlers are triggered by notify, and they only fire when the notifying task reports changed. That sounds safe. It is not, if your tasks are incorrectly reporting changed when nothing actually changed.
- name: Deploy Nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart Nginx
If the template module correctly reports ok on the second run — because the file is already identical — the handler does not fire. Good.
But if something earlier in the play incorrectly reports changed and that task also notifies the same handler, Nginx restarts when it does not need to. On a production system, unnecessary restarts cause unnecessary downtime.
The fix is the same: ensure every task accurately reports its actual state. Test it by running the playbook twice against a live system and reading the output carefully — not just the final summary, but each individual task.
Testing idempotency properly
There are two approaches, and they are not the same.
--check mode runs Ansible in dry-run, predicting what would change without touching anything. It catches some issues but not all — some modules behave differently in check mode, and tasks that depend on prior task results can produce inaccurate predictions.
The only reliable test is to run the playbook twice against a real system and inspect the second run:
PLAY RECAP
server01 : ok=12 changed=0 unreachable=0 failed=0
changed=0 on the second run means the playbook correctly converged. Any non-zero changed count means at least one task is not idempotent, and you need to find it.
In the EpicBook capstone, this was a required gate — not something checked once at the end, but something verified after each role was written. A role that failed the second-run test did not get merged.
The takeaway
Idempotency is a design property, not a side effect of using Ansible. The modules that provide it automatically are the ones built around state. The modules that do not — command, shell, raw — require you to implement the state check yourself, or accept that your automation is a sequence of operations rather than a description of desired state.
The difference matters most when something goes wrong mid-run and you need to re-apply. A non-idempotent playbook applied twice to a partially-configured system is not a recovery tool. It is another source of failure.
Write the second run into your test process. changed=0 is not the goal — it is the baseline.