You should be able to run the same Ansible playbook again without fear—after a failed run, a scheduled job, or a teammate's deploy. Idempotency means describing the state you want and letting modules decide whether work is needed.
This guide focuses on repeatable, state-based automation: reading ok vs changed, choosing the right modules, and fixing tasks that change the system every time. It assumes you can run a basic playbook from your first playbook tutorial and understand playbook structure. For deep dives on command vs shell, see command vs shell vs raw; for every run flag, see how to run playbooks.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
| Task style | Repeatable? | Better approach |
|---|---|---|
shell: dnf install -y tree |
Poor | package: name=tree state=present |
command: mkdir /opt/app |
Poor | file: path=/opt/app state=directory |
shell: systemctl restart httpd |
Poor | Handler notified by config change |
copy with same content |
Good | Reports ok when file already matches |
template with stable variables |
Good | Changes only when rendered content differs |
The package row matches what EX294 expects on Rocky Linux—see manage packages on Rocky Linux for dnf, repositories, and module choice beyond this demo.
What is Idempotency in Ansible?
In Ansible, idempotency means you declare desired state—package installed, file present, service running—and the module checks the current state before acting. If the system already matches, the task finishes with ok and no change.
Official playbook guidance describes this model: most modules compare current and desired state, then exit without changes when nothing is required. That is why rerunning a well-written playbook is safe; only drift or new requirements produce changed.
Why Idempotency Matters in Playbooks
| Benefit | What you gain |
|---|---|
| Safe reruns | Recover from partial failures without double-applying work |
| Predictable automation | CI/CD and cron jobs do not surprise you with extra edits |
| Less downtime | Services restart only when something actually changed |
| Easier reviews | State-based tasks read as intent, not one-off shell scripts |
Playbooks that always report changed train teams to ignore output—and that is when real problems hide in the noise.
ok vs changed vs failed in Ansible Output
Each task line per host shows a status. PLAY RECAP totals them at the end.
| Status | Meaning |
|---|---|
ok |
Task succeeded; desired state already met or no change recorded |
changed |
Task succeeded and modified the system |
failed |
Task could not complete (module error, bad args, missing become) |
unreachable |
Ansible could not connect or execute on the host |
skipped |
Task not run (condition, tag, or creates/removes guard) |
In recap, ok=3 changed=0 on a second run is what you want for a stable playbook.
Idempotent vs Non-Idempotent Tasks
Idempotent (typical): package with state: present, file with state: directory, copy when content matches.
Non-idempotent (typical): shell or command that always runs a program, shell: systemctl restart … every play, templates that embed ansible_date_time so rendered content never matches.
The goal is not zero changed lines forever—real updates should still show changed. The goal is that unchanged systems stay unchanged on rerun.
Use State-Based Modules for Idempotency
Reach for modules that accept a state (or equivalent) before command or shell:
| Module | Example desired state |
|---|---|
package |
state: present / absent |
service |
state: started / stopped, enabled: true |
file |
state: directory, file, absent, link |
user |
state: present with name, uid, groups |
copy |
Content at dest matches src |
template |
Rendered file matches variables |
Create a small package demo:
cat > ~/ansible-project/playbooks/idempotency-package.yml << 'EOF'
---
- name: Package idempotency demo
hosts: lab
gather_facts: false
become: true
tasks:
- name: Ensure tree is installed
ansible.builtin.package:
name: tree
state: present
EOFFirst run—package was missing, so Ansible installs it:
cd ~/ansible-project
ansible-playbook playbooks/idempotency-package.ymlSample output:
TASK [Ensure tree is installed] ************************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Second run—the package module sees tree already installed:
ansible-playbook playbooks/idempotency-package.ymlSample output:
TASK [Ensure tree is installed] ************************************************
ok: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The module knows the desired state; Ansible is not idempotent by magic—your task choice makes it so.
Avoid Misusing command and shell
command and shell execute a program. Unless you add guards or changed_when, Ansible assumes a successful run may have changed something—shell often reports changed every time.
cat > ~/ansible-project/playbooks/idempotency-shell-bad.yml << 'EOF'
---
- name: Shell non-idempotent demo
hosts: lab
gather_facts: false
become: true
tasks:
- name: Create marker with shell
ansible.builtin.shell: echo done > /tmp/idempotency-marker.txt
EOFRun it twice:
ansible-playbook playbooks/idempotency-shell-bad.ymlSample output:
TASK [Create marker with shell] ************************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0ansible-playbook playbooks/idempotency-shell-bad.ymlSample output:
TASK [Create marker with shell] ************************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Same file, same content—still changed both times. Prefer copy or file for file content and paths. Use shell only when no module fits; see command vs shell vs raw.
Use creates and removes with command and shell
When you must use command, limit runs with path guards documented for those modules:
creates— skip if the path already existsremoves— skip if the path does not exist
cat > ~/ansible-project/playbooks/idempotency-creates.yml << 'EOF'
---
- name: Creates guard demo
hosts: lab
gather_facts: false
become: true
tasks:
- name: Run setup script once
ansible.builtin.command:
cmd: touch /tmp/idempotency-setup.done
creates: /tmp/idempotency-setup.done
EOFRemove the marker if it exists from earlier tests, then run twice:
ansible lab -m file -a "path=/tmp/idempotency-setup.done state=absent" -bansible-playbook playbooks/idempotency-creates.ymlSample output:
TASK [Run setup script once] ***************************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0ansible-playbook playbooks/idempotency-creates.ymlSample output:
TASK [Run setup script once] ***************************************************
ok: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The second run skips the command because the marker file exists—changed=0 even though the task line shows ok.
Control Changed Status with changed_when
Some modules always return changed: true, or you run a read-only command that should never count as a change. Set changed_when on the task (often with register).
cat > ~/ansible-project/playbooks/idempotency-changed-when.yml << 'EOF'
---
- name: changed_when demo
hosts: lab
gather_facts: false
tasks:
- name: Check if tree is installed
ansible.builtin.command: rpm -q tree
register: tree_check
failed_when: false
changed_when: false
- name: Show result
ansible.builtin.debug:
msg: "tree package query rc={{ tree_check.rc }}"
EOFansible-playbook playbooks/idempotency-changed-when.ymlSample output:
TASK [Check if tree is installed] **********************************************
ok: [rocky2]
TASK [Show result] *************************************************************
ok: [rocky2] => {
"msg": "tree package query rc=0"
}
PLAY RECAP *********************************************************************
rocky2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0rpm -q is a check, not a change—changed_when: false keeps recap honest. Use expressions like changed_when: result.stdout != "expected" when output defines whether a change happened.
Control Failure Status with failed_when
Commands return non-zero exit codes for benign cases—"package not installed" is often rc=1, not a playbook failure. Combine register, failed_when, and changed_when: false:
cat > ~/ansible-project/playbooks/idempotency-failed-when.yml << 'EOF'
---
- name: failed_when demo
hosts: lab
gather_facts: false
tasks:
- name: Look for optional package
ansible.builtin.command: rpm -q nonexistent-pkg-xyz
register: pkg_check
failed_when: pkg_check.rc not in [0, 1]
changed_when: false
- name: Report
ansible.builtin.debug:
msg: "Package not installed (expected rc=1)"
when: pkg_check.rc == 1
EOFansible-playbook playbooks/idempotency-failed-when.ymlSample output:
TASK [Look for optional package] ***********************************************
ok: [rocky2]
TASK [Report] ******************************************************************
ok: [rocky2] => {
"msg": "Package not installed (expected rc=1)"
}
PLAY RECAP *********************************************************************
rocky2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Without failed_when, rc=1 would fail the play. More patterns live in command vs shell vs raw.
Idempotency and Handlers
Restarting a service after every run is not idempotent. Handlers run only when a notifying task reports changed, typically at the end of the play.
mkdir -p ~/ansible-project/playbooks/templates
cat > ~/ansible-project/playbooks/templates/app.conf.j2 << 'EOF'
# Managed by Ansible
app_name={{ app_name }}
EOFcat > ~/ansible-project/playbooks/idempotency-handler.yml << 'EOF'
---
- name: Handler idempotency demo
hosts: lab
gather_facts: false
become: true
vars:
app_name: demo
tasks:
- name: Ensure config directory exists
ansible.builtin.file:
path: /tmp/idempotency-demo
state: directory
mode: "0755"
- name: Deploy app config
ansible.builtin.template:
src: app.conf.j2
dest: /tmp/idempotency-demo/app.conf
mode: "0644"
notify: Record handler run
handlers:
- name: Record handler run
ansible.builtin.copy:
content: "handler ran after config change\n"
dest: /tmp/idempotency-demo/handler-ran.txt
mode: "0644"
EOFFirst run—template changes, handler runs:
ansible-playbook playbooks/idempotency-handler.ymlSample output:
TASK [Deploy app config] *******************************************************
changed: [rocky2]
RUNNING HANDLER [Record handler run] *******************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=3 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Second run—config unchanged, handler skipped:
ansible-playbook playbooks/idempotency-handler.ymlSample output:
TASK [Deploy app config] *******************************************************
ok: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0No RUNNING HANDLER line on the second run—that is the idempotent restart pattern.
Validate Idempotency with Repeated Runs
Make repeated runs a habit:
- Run the playbook normally and note
PLAY RECAP - Run the same command again immediately
- Expect
changed=0(or only tasks that legitimately drifted)
For the package demo, run one after the other:
ansible-playbook playbooks/idempotency-package.yml
ansible-playbook playbooks/idempotency-package.ymlThe second recap should show changed=0. If changed stays high, inspect tasks that use shell, bare command, systemctl restart, or templates with timestamps.
Use Check Mode and Diff Mode
Before a risky change, preview with --check (and --diff for files). Check mode asks supporting modules what would change without applying it—not every module supports it fully.
Not every module predicts changes equally in check mode. State-aware modules such as package, file, copy, and template usually report would-change accurately; command, shell, and custom scripts may always show ok or behave unpredictably because Ansible cannot know side effects without running the program. Read ansible-doc MODULE for check-mode support on each module you rely on, and treat --check as a preview—not proof of idempotency.
Check mode is not the same as an idempotency test. A playbook can look clean in check mode and still report repeated changed during real runs if a task always rewrites content or always restarts a service.
ansible-playbook playbooks/idempotency-package.yml --checkSample output:
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0When tree is already installed, check mode reports no pending change. Full examples of --check, --diff, and limits are in how to run playbooks.
Common Idempotency Problems and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
shell task changed every run |
No guard or changed_when |
Use file/copy/package, or creates/removes |
template always changed |
Dynamic content (ansible_date_time, random) |
Remove volatile values from template; use stable vars |
package not state-based |
Using shell with dnf install |
package / dnf / apt with state: |
command fails on rerun |
Assumes resource absent | creates/removes, or switch to file state: absent |
| Check mode surprises | Module partial/no check support | Test on lab host; read module docs |
Safer Alternatives to Non-Idempotent Commands
| Instead of | Use |
|---|---|
shell: dnf install -y pkg |
ansible.builtin.package |
command: mkdir -p /opt/app |
ansible.builtin.file state: directory |
shell: useradd myuser |
ansible.builtin.user |
shell: systemctl restart httpd |
Handler after template/copy notify |
shell: echo line >> /etc/sysctl.conf |
ansible.builtin.lineinfile or ansible.posix.sysctl |
shell: curl … | bash |
package + copy/template + guarded command with creates |
When no module exists, register output and narrow changed_when / failed_when instead of accepting default changed: true.
Recommended Idempotency Workflow
- Describe state with modules (
package,file,copy,template,service) - Run
ansible-playbook play.yml --syntax-checkafter edits - Run the playbook on a lab host
- Run the same playbook again—confirm
changed=0where expected - Use
--check --diffbefore production file or package changes - Put restarts and reloads in handlers notified by real config changes
- Reserve
command/shellfor gaps—addcreates,removes, orchanged_when
Summary
Idempotent playbooks declare desired state and rely on state-aware modules to decide whether work is needed. Read ok as "already correct," changed as "Ansible fixed drift," and treat every recurring changed from shell as a design smell. Use creates and removes when commands are unavoidable; use changed_when and failed_when for checks; use handlers for restarts; validate with back-to-back runs and check mode before you trust automation in production.
References
- Ansible playbooks introduction — desired state and idempotency
- Handlers — notify and run once
- command module —
createsandremoves - Ansible playbook structure — plays, tasks, handlers
- command vs shell vs raw — guards and
register - Run Ansible playbooks —
--checkand--diff

