AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting
The operations that cannot be idempotent
What you'll learn
- Identify operations that have no idempotent formulation
- Fence a non-idempotent task so the surrounding play stays rerunnable
- Choose between a marker file, a probe-derived condition and operator confirmation
- Record an idempotency exception in the code as a deliberate decision
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
Some operations have no idempotent form. Not “are hard to write idempotently” — genuinely cannot be expressed as a state to converge on, because the operation is an event rather than a state.
Pretending otherwise is how playbooks acquire their most dangerous tasks. The honest approach is to name the exception, fence it, and keep the play around it rerunnable.
The four shapes
A reboot. “The host should be rebooted” is not a state. Uptime is a
consequence of an event, and there is no configuration you can read to
decide whether a reboot is currently required — only proxies for it, such
as /var/run/reboot-required on Debian family systems or a kernel
version that differs from the running one.
A one-shot data migration. Running it twice may duplicate rows, double-apply a transformation, or fail on a constraint. The desired state is “the migration has been applied”, which is not observable from the data itself unless the migration tool keeps its own ledger. Good ones do; that is what a migrations table is for.
An API call with no read side. “Send a notification”, “open a change ticket”, “trigger a pipeline”. There is no query that answers “has this already been sent?”, so there is nothing to compare against.
A vendor installer. A single opaque binary that installs, upgrades, reconfigures and restarts, exits 0, and offers no way to ask what state it found. Running it twice is a coin flip: it may be a no-op, it may reinstall from scratch and restart the service.
Fence one: a marker with creates
The simplest fence, and the right one when the operation genuinely happens once per host in its lifetime.
- name: Apply the one-time data migration
ansible.builtin.command: /opt/app/bin/migrate --apply-baseline
args:
creates: /var/lib/app/.baseline-migration-applied
The module stats the path before doing anything. If it exists, the
command is not executed and the task reports ok with an explanation:
$ ansible-playbook -i inv2.ini creates.yml -vTASK [One-shot step, marker absent] ********************************************
changed: [localhost] => {"changed": true, "cmd": ["/bin/echo", "running-the-one-shot-step"], "rc": 0}
TASK [One-shot step, marker present] *******************************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/echo", "would-have-run"], "msg": "Did not run command since '/etc/hostname' exists", "rc": 0}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The task is now idempotent from the play’s point of view: run it a
hundred times, the command executes once. changed=0 is reachable
again, which is the property lesson 7 depends on.
Three things to get right about markers.
The command must create the marker, or a following task must. If
the command does not write the file itself, add a file task
immediately after — and understand that a failure between the two leaves
the operation done and the marker absent, so the next run repeats it.
Put it somewhere durable and meaningful. /var/lib/<app>/ is
reasonable; /tmp is not, because it does not survive a reboot on many
distributions.
Name it after the operation, with a version.
.baseline-migration-applied is better than .done, and
.migration-2026-03-schema-v4 is better still, because the next
migration needs its own marker and .done has already been taken.
Fence two: a probe-derived condition
Stronger, because it checks the actual outcome rather than a proxy for it.
- name: Read the schema version the database reports
ansible.builtin.command: /opt/app/bin/migrate --current-version
register: schema
changed_when: false
- name: Apply the pending migration
ansible.builtin.command: /opt/app/bin/migrate --apply
when: schema.stdout | trim != 'v4'
On a converged host the second task is skipped, so it cannot report
changed. On a host that needs it, it runs and reports changed
honestly.
This is the same shape as the probe pattern in lesson 1, and it is the
best available answer when the operation has any observable result at
all. The probe is unconditional, which matters: a probe guarded by its
own when: can be skipped, and a skipped probe registers a result with
no stdout in it, so the guarded task then fails on an undefined
attribute.
For a reboot, the equivalent probe is a real one:
- name: Check whether the system is asking to be rebooted
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_flag
- name: Reboot to complete the kernel upgrade
ansible.builtin.reboot:
reboot_timeout: 600
when: reboot_flag.stat.exists
ansible.builtin.stat is read-only, and the flag file is written by the
package manager rather than by you. The patching part covers reboot
orchestration properly — batching, health checks and the recovery path
when a host does not come back.
Fence three: an explicit operator decision
Some operations should not be automatic on any run, however well fenced. A destructive migration, a licence activation with a fixed number of uses, a failover.
- name: Perform the destructive schema rewrite
ansible.builtin.command: /opt/app/bin/migrate --rewrite --confirm
when: confirm_destructive_rewrite | default(false) | bool
Invoked with nothing, the task is skipped. Invoked with
-e confirm_destructive_rewrite=true, it runs. The default is safe, the
override is explicit, and it appears in the shell history and the CI job
definition of whoever chose it.
This is a guardrail, not a fence: it does not make the operation idempotent, it makes it require a decision. Combine it with a real fence where one exists — a confirmed operation that is also marker-fenced cannot be run twice by mistake even after confirmation.
Record that it was a decision
A fenced non-idempotent task looks, to the next reader, like a task somebody wrote carelessly and then patched. Say so in the file:
# Non-idempotent by nature: the vendor installer reinstalls and restarts
# on every invocation and offers no way to query the installed version.
# Fenced with a marker so the surrounding play stays rerunnable.
# Reviewed 2026-08-11. If vendor-cli gains a `status` subcommand, replace
# this with a probe-derived condition.
- name: Install the vendor agent
ansible.builtin.command: /opt/vendor/install.sh --silent
args:
creates: /opt/vendor/.installed
Four lines of comment that answer the three questions the next person will have: why is this not a module, why is the fence a marker rather than a probe, and what would have to change for it to be improved.
Without them, the fence gets removed by somebody tidying up, or duplicated by somebody who did not realise it was deliberate.
Knowledge check
Knowledge check · 4 questions
Q1. Why is a reboot not expressible as a declarative state?
Q2. A migration is fenced by a marker file that a following task creates. The migration succeeds and the marker task fails. What does the next run do?
Q3. Which operations genuinely have no idempotent formulation? Select all that apply.
Q4. Guarding a destructive task with when: confirm_rewrite | default(false) is safe, because an operator passing confirm_rewrite=false on the command line will skip it.
Passing score: 75%. Answers are checked in this browser.