Ansible OEL 8 DevOps · OEL 8 · Concept

AnsibleIdempotency — Why You Can Re-run Forever

The single most important concept in Ansible — what idempotency is, why it makes Ansible safe to run repeatedly, when modules break the rule, and how to make even raw shell commands idempotent.

A task is idempotent when running it once and running it ten times produce exactly the same end state. The first run might create something; subsequent runs detect that the desired state already exists and do nothing.

Task declared in playbook - ansible.builtin.user: name: appuser state: present shell: /bin/bash desired state, not steps 1st run user does not exist CHANGED → Ansible creates appuser 2nd run user already exists OK → no change made Nth run state still matches OK → safe to re-run forever Same input · Same target state · Same outcome — that's idempotency

Most Ansible modules are idempotent by design. The user module doesn't blindly "create user X" — it checks whether X already exists with the right shell and group membership, and only acts if reality differs from what you declared.

  • Re-runs are safe. A failed playbook can be re-run after fixing the bad task — the parts that already succeeded won't be re-done.
  • Configuration drift detection. Run the playbook on a schedule. Anything reported as changed means someone touched the host out-of-band.
  • Convergence over time. Add five new servers and run the same playbook. Existing servers stay quiet, new ones get configured.
  • Reasoning becomes simpler. You declare end-state, not steps. If the playbook ran clean, the state is correct.

Almost all of them. A few examples:

YAML — idempotent task examples
# user — checks if user exists with same attributes; only changes if different
- ansible.builtin.user:
    name: appuser
    shell: /bin/bash
    groups: wheel
    append: true

# dnf — checks if package is installed at requested version
- ansible.builtin.dnf:
    name: nginx
    state: present

# copy — compares checksums; only ships file if checksum differs
- ansible.builtin.copy:
    src: ./files/my.cnf
    dest: /etc/my.cnf

# lineinfile — only writes if the regex match doesn't already produce the line
- ansible.builtin.lineinfile:
    path: /etc/sysctl.conf
    regexp: "^vm.swappiness"
    line: "vm.swappiness = 10"

# systemd — checks current service state before acting
- ansible.builtin.systemd:
    name: mysqld
    state: started
    enabled: true

A small set of modules always report changed because they can't see what the remote command will do without running it:

ModuleWhy it's not idempotentWhat to do
command / shellAnsible can't predict what arbitrary shell doesAdd a creates: or removes: guard
rawBypasses the module subsystem entirelyOnly use for bootstrap (e.g. installing Python on barebones hosts)
scriptRuns an arbitrary script — same problemAdd creates: or rewrite as a real module call
uri with POST/PUT/DELETEHTTP side effects can't be predictedAdd changed_when: based on response body
YAML — make command idempotent with creates
# WRONG — runs every time, always reports changed
- name: Initialise mysql data directory
  ansible.builtin.command: mysqld --initialize-insecure

# RIGHT — only runs if /var/lib/mysql/ibdata1 does NOT exist yet
- name: Initialise mysql data directory
  ansible.builtin.command: mysqld --initialize-insecure
  args:
    creates: /var/lib/mysql/ibdata1
YAML — make command idempotent with removes
# only runs if /tmp/cleanup.flag DOES exist (i.e. work to do)
- name: Tidy up after install
  ansible.builtin.command: rm -rf /opt/staging
  args:
    removes: /tmp/cleanup.flag

For complex commands you can override Ansible's idea of what counts as "changed" or "failed" by inspecting the command's rc, stdout, or stderr:

YAML — custom changed_when / failed_when
- name: Check if MySQL replication is running
  ansible.builtin.command: mysql -BNe "SHOW SLAVE STATUS\\G" --skip-column-names
  register: slave_status

  # Only mark as changed if Slave_IO_Running is No
  changed_when: "'Slave_IO_Running: No' in slave_status.stdout"

  # Don't fail if the SQL fails — we want to inspect the result
  failed_when: false

A handler is just a task that runs only when notified — and only once per play even if notified five times. Combined with idempotent triggering tasks, this means:

YAML — idempotent handler
---
- hosts: db
  tasks:
    - name: Tweak my.cnf
      ansible.builtin.lineinfile:
        path: /etc/my.cnf
        regexp: "^max_connections"
        line: "max_connections = 500"
      notify: restart mysql

  handlers:
    - name: restart mysql
      ansible.builtin.systemd:
        name: mysqld
        state: restarted

# 1st run: lineinfile reports CHANGED → handler runs (mysql restarts)
# 2nd run: lineinfile reports OK     → handler does NOT run
✅ Tip: That's the dream: a playbook you can run on a 5-minute cron and have it consume zero CPU on the managed host until something genuinely drifts. Idempotency is what makes that possible.

You now have everything you need to write, read, and reason about Ansible playbooks. The next 16 pages — Section 2: Playbook Fundamentals and Section 3: Roles and Reuse — go deep on variables, Jinja2, conditionals, loops, handlers, error handling, and the role/collection structure that turns scattered playbooks into a reusable library.