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.
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
changedmeans 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:
# 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:
| Module | Why it's not idempotent | What to do |
|---|---|---|
command / shell | Ansible can't predict what arbitrary shell does | Add a creates: or removes: guard |
raw | Bypasses the module subsystem entirely | Only use for bootstrap (e.g. installing Python on barebones hosts) |
script | Runs an arbitrary script — same problem | Add creates: or rewrite as a real module call |
uri with POST/PUT/DELETE | HTTP side effects can't be predicted | Add changed_when: based on response body |
# 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
# 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:
- 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:
---
- 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
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.