Ansible Idempotency Explained: Write Safe Repeatable Playbooks

Learn Ansible idempotency—ok vs changed, state-based modules, avoiding shell misuse, creates and removes, changed_when, handlers, repeated-run checks, and check mode.

Published

Updated

Read time 10 min read

Reviewed byDeepak Prasad

Ansible idempotency with ok and changed task results on Rocky Linux 10

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.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/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:

bash
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
EOF

First run—package was missing, so Ansible installs it:

bash
cd ~/ansible-project
ansible-playbook playbooks/idempotency-package.yml

Sample output:

output
TASK [Ensure tree is installed] ************************************************
changed: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Second run—the package module sees tree already installed:

bash
ansible-playbook playbooks/idempotency-package.yml

Sample output:

output
TASK [Ensure tree is installed] ************************************************
ok: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

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

bash
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
EOF

Run it twice:

bash
ansible-playbook playbooks/idempotency-shell-bad.yml

Sample output:

output
TASK [Create marker with shell] ************************************************
changed: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
bash
ansible-playbook playbooks/idempotency-shell-bad.yml

Sample output:

output
TASK [Create marker with shell] ************************************************
changed: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Same 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 exists
  • removes — skip if the path does not exist
bash
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
EOF

Remove the marker if it exists from earlier tests, then run twice:

bash
ansible lab -m file -a "path=/tmp/idempotency-setup.done state=absent" -b
bash
ansible-playbook playbooks/idempotency-creates.yml

Sample output:

output
TASK [Run setup script once] ***************************************************
changed: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
bash
ansible-playbook playbooks/idempotency-creates.yml

Sample output:

output
TASK [Run setup script once] ***************************************************
ok: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The 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).

bash
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 }}"
EOF
bash
ansible-playbook playbooks/idempotency-changed-when.yml

Sample output:

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=0

rpm -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:

bash
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
EOF
bash
ansible-playbook playbooks/idempotency-failed-when.yml

Sample output:

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=0

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

bash
mkdir -p ~/ansible-project/playbooks/templates
cat > ~/ansible-project/playbooks/templates/app.conf.j2 << 'EOF'
# Managed by Ansible
app_name={{ app_name }}
EOF
bash
cat > ~/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"
EOF

First run—template changes, handler runs:

bash
ansible-playbook playbooks/idempotency-handler.yml

Sample output:

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=0

Second run—config unchanged, handler skipped:

bash
ansible-playbook playbooks/idempotency-handler.yml

Sample output:

output
TASK [Deploy app config] *******************************************************
ok: [rocky2]

PLAY RECAP *********************************************************************
rocky2                     : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

No RUNNING HANDLER line on the second run—that is the idempotent restart pattern.


Validate Idempotency with Repeated Runs

Make repeated runs a habit:

  1. Run the playbook normally and note PLAY RECAP
  2. Run the same command again immediately
  3. Expect changed=0 (or only tasks that legitimately drifted)

For the package demo, run one after the other:

bash
ansible-playbook playbooks/idempotency-package.yml
ansible-playbook playbooks/idempotency-package.yml

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

bash
ansible-playbook playbooks/idempotency-package.yml --check

Sample output:

output
PLAY RECAP *********************************************************************
rocky2                     : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

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


  1. Describe state with modules (package, file, copy, template, service)
  2. Run ansible-playbook play.yml --syntax-check after edits
  3. Run the playbook on a lab host
  4. Run the same playbook again—confirm changed=0 where expected
  5. Use --check --diff before production file or package changes
  6. Put restarts and reloads in handlers notified by real config changes
  7. Reserve command/shell for gaps—add creates, removes, or changed_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 modulecreates and removes
  • Ansible playbook structure — plays, tasks, handlers
  • command vs shell vs raw — guards and register
  • Run Ansible playbooks — --check and --diff

Frequently Asked Questions

1. What does idempotent mean in Ansible?

An idempotent task describes desired state. When the system already matches that state, Ansible reports ok without changing anything. You can rerun the playbook safely.

2. Why does my shell task show changed every time?

command and shell run the program each time unless you add creates, removes, or changed_when. Prefer state-aware modules such as file, package, or copy when possible.

3. What is the difference between ok and changed?

ok means the task succeeded and the desired state was already present or no change was needed. changed means Ansible modified the system to reach the desired state.

4. Do handlers run on every playbook execution?

No. Handlers run only when a notifying task reports changed, and even then at the end of the play batch. A second run with no changes skips handlers.

5. How do I test idempotency?

Run the playbook twice. The second run should show mostly ok and changed=0 in PLAY RECAP unless real drift exists or a task is not idempotent.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …