Skip to main content
RunBook Academy

AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting

The operations that cannot be idempotent

Intermediate⏱ ~17 minansible-playbook

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

Not yet marked complete on this device.

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:

Read-only / Safethe fence, on both sides
$ ansible-playbook -i inv2.ini creates.yml -v
TASK [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=0

The 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

  1. Q1. Why is a reboot not expressible as a declarative state?

  2. 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?

  3. Q3. Which operations genuinely have no idempotent formulation? Select all that apply.

  4. 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.