Master Ansible idempotency to write playbooks that safely run multiple times without side effects. Learn how to build production-grade automation that prevents configuration drift and infrastructure chaos.

Idempotency is the most important concept in Ansible, yet it's often misunderstood. An idempotent operation produces the same result whether you run it once or a hundred times. Run it once, your system reaches the desired state. Run it again, nothing changes. Run it a third time, still nothing changes.
This is the opposite of imperative scripts. A bash script that runs apt-get install nginx twice will fail the second time because nginx is already installed. An Ansible playbook that installs nginx is idempotent—it checks if nginx is installed, and if it is, it does nothing.
Idempotency is what makes Ansible safe for production. It's why you can schedule playbooks to run every hour without fear of breaking your infrastructure. It's why you can re-run a failed deployment without manually cleaning up partial changes.
This guide covers how to write idempotent Ansible code and why it matters.
Traditional shell scripts are imperative. They describe a sequence of commands to execute:
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
echo "server_name example.com;" >> /etc/nginx/nginx.conf
systemctl restart nginxRun this script once, and it works. Run it twice:
apt-get install nginx fails because nginx is already installedecho command appends the same line again, duplicating configurationsystemctl restart might fail if nginx is already runningThe script isn't designed to be run multiple times. It assumes a clean slate.
Idempotent operations check the current state before making changes:
---
- name: Configure nginx
hosts: webservers
tasks:
- name: Install nginx
apt:
name: nginx
state: present
- name: Start nginx
systemd:
name: nginx
state: started
enabled: yes
- name: Configure nginx
lineinfile:
path: /etc/nginx/nginx.conf
line: "server_name example.com;"
state: present
- name: Reload nginx
systemd:
name: nginx
state: reloadedRun this playbook once, and nginx is installed and configured. Run it again, and Ansible checks:
Nothing breaks. Nothing duplicates. The system reaches the desired state and stays there.
Idempotency enables several critical practices:
Scheduled automation. Run your playbooks hourly to detect and fix configuration drift. If a sysadmin manually edits a config file, the next playbook run fixes it.
Safe retries. If a playbook fails halfway through, re-run it without worrying about partial changes breaking things.
Infrastructure as Code. Your playbooks become the source of truth. The actual infrastructure should match what your playbooks describe.
Disaster recovery. After a server outage, re-run your playbooks to restore the exact configuration without manual intervention.
Most Ansible modules are idempotent by design. They check the current state and only make changes if necessary. The apt module is idempotent:
- name: Install nginx
apt:
name: nginx
state: presentRun this once, nginx installs. Run it again, Ansible checks if nginx is installed, sees it is, and does nothing. The task reports changed: false.
Some modules perform actions that can't be made idempotent. The shell and command modules execute arbitrary commands without checking state:
- name: Create a file
shell: touch /tmp/myfile.txtRun this once, the file is created. Run it again, the command runs again, but the file already exists so nothing visible changes. However, Ansible reports changed: true every time because it can't know if the command had side effects.
This is dangerous. If you use shell to restart a service, running the playbook twice restarts the service twice, potentially causing downtime.
Use the creates, removes, or changed_when parameters to make non-idempotent modules behave idempotently:
- name: Generate SSL certificate
shell: openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/key.pem -out /etc/ssl/certs/cert.pem
args:
creates: /etc/ssl/certs/cert.pemThe creates parameter tells Ansible: "Only run this command if /etc/ssl/certs/cert.pem doesn't exist." If the file exists, Ansible skips the command and reports changed: false.
Similarly, use removes to skip a command if a file exists:
- name: Clean up old logs
shell: rm -rf /var/log/old/*
args:
removes: /var/log/oldFor commands where you can't use creates or removes, use changed_when:
- name: Check if service is running
shell: systemctl is-active nginx
register: nginx_status
changed_when: falseThe changed_when: false tells Ansible this command never makes changes, so always report changed: false.
A common pattern is to restart a service after configuration changes:
- name: Update nginx config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
- name: Restart nginx
systemd:
name: nginx
state: restartedThis restarts nginx every time the playbook runs, even if the configuration didn't change. This causes unnecessary downtime.
Handlers are tasks that only run if another task reports changed: true. They're perfect for restarting services:
- name: Configure nginx
hosts: webservers
tasks:
- name: Update nginx config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
notify: restart nginx
handlers:
- name: restart nginx
systemd:
name: nginx
state: restartedNow nginx only restarts if the configuration file actually changed. If you run the playbook again and the config file is identical, the copy task reports changed: false, and the handler never runs.
Handlers run at the end of a play, after all tasks complete. This prevents multiple restarts if multiple tasks notify the same handler:
- name: Configure nginx
hosts: webservers
tasks:
- name: Update main config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
notify: restart nginx
- name: Update SSL config
copy:
src: ssl.conf
dest: /etc/nginx/conf.d/ssl.conf
notify: restart nginx
- name: Update security headers
copy:
src: security.conf
dest: /etc/nginx/conf.d/security.conf
notify: restart nginx
handlers:
- name: restart nginx
systemd:
name: nginx
state: restartedEven though three tasks notify the handler, nginx restarts only once, at the end of the play. This is more efficient and safer than restarting after each change.
when for Conditional TasksThe when clause lets you run tasks only under certain conditions:
- name: Install nginx on Debian
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Install nginx on RedHat
yum:
name: nginx
state: present
when: ansible_os_family == "RedHat"This is idempotent because each task checks the condition before running. On a Debian system, the RedHat task never runs.
Use stat or command with changed_when: false to check if something exists:
- name: Check if SSL certificate exists
stat:
path: /etc/ssl/certs/cert.pem
register: cert_file
- name: Generate SSL certificate
shell: openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/key.pem -out /etc/ssl/certs/cert.pem
when: not cert_file.stat.existsThe stat module checks if the certificate exists without making changes. The shell task only runs if the certificate doesn't exist.
The template module is idempotent. It renders a Jinja2 template and copies it to the destination:
- name: Deploy application config
template:
src: app.conf.j2
dest: /etc/app/app.conf
owner: root
group: root
mode: '0644'
notify: restart appAnsible compares the rendered template with the existing file. If they're identical, it reports changed: false and the handler doesn't run. If they differ, it updates the file and notifies the handler.
For critical configuration files, validate the syntax before deploying:
- name: Deploy nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: /usr/sbin/nginx -t -c %s
notify: reload nginxThe validate parameter runs a command to check the configuration. If validation fails, Ansible doesn't deploy the file and reports an error. This prevents broken configurations from reaching production.
shell Instead of Proper Modules- name: Install nginx
shell: apt-get install -y nginxThis isn't idempotent. Use the apt module instead:
- name: Install nginx
apt:
name: nginx
state: present- name: Add line to config
shell: echo "new_setting=value" >> /etc/app/config.confThis appends the line every time. Use lineinfile instead:
- name: Add line to config
lineinfile:
path: /etc/app/config.conf
line: "new_setting=value"
state: present- name: Create directory
shell: mkdir /opt/app
ignore_errors: yesThis hides real errors. Use proper modules:
- name: Create directory
file:
path: /opt/app
state: directory
mode: '0755'- name: Update config
copy:
src: app.conf
dest: /etc/app/app.conf
- name: Restart app
systemd:
name: app
state: restartedThis restarts the service every time. Use handlers:
- name: Update config
copy:
src: app.conf
dest: /etc/app/app.conf
notify: restart app
handlers:
- name: restart app
systemd:
name: app
state: restartedAnsible has modules for almost everything. Use them. They're idempotent, well-tested, and maintainable.
# Good
- name: Install packages
apt:
name: "{{ item }}"
state: present
loop:
- nginx
- curl
- git
# Avoid
- name: Install packages
shell: apt-get install -y nginx curl gitchanged_when and failed_when ExplicitlyMake your intent clear:
- name: Check service status
shell: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: nginx_status.rc not in [0, 3]Use the validate parameter when deploying configuration files:
- name: Deploy nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: /usr/sbin/nginx -t -c %s
notify: reload nginxNever restart services directly in tasks. Use handlers:
tasks:
- name: Update config
copy:
src: app.conf
dest: /etc/app/app.conf
notify: restart app
handlers:
- name: restart app
systemd:
name: app
state: restartedAlways test with --check before running in production:
ansible-playbook site.yml --checkThis shows what would change without actually making changes.
Run your playbooks twice in your CI/CD pipeline. The second run should report no changes:
#!/bin/bash
ansible-playbook site.yml
FIRST_RUN=$?
ansible-playbook site.yml
SECOND_RUN=$?
if [ $FIRST_RUN -ne 0 ] || [ $SECOND_RUN -ne 0 ]; then
echo "Playbook failed"
exit 1
fi
# Check that second run made no changes
if ansible-playbook site.yml --check | grep -q "changed=0"; then
echo "Playbook is idempotent"
else
echo "Playbook is not idempotent"
exit 1
fiRun your playbook twice and verify the second run makes no changes:
# First run
ansible-playbook site.yml
# Second run - should show changed=0
ansible-playbook site.ymlLook for changed=0 in the output. If any tasks show changed=1 on the second run, your playbook isn't idempotent.
Molecule is a testing framework for Ansible. It can test idempotency automatically:
---
driver:
name: docker
platforms:
- name: ubuntu
image: ubuntu:22.04
provisioner:
name: ansible
verifier:
name: ansible
scenario:
name: default
test_sequence:
- lint
- destroy
- dependency
- create
- prepare
- converge
- idempotence
- verify
- destroyThe idempotence step runs your playbook twice and verifies the second run makes no changes.
Some tasks are genuinely one-time operations. Use changed_when: false to acknowledge this:
- name: Initialize database
shell: /opt/app/bin/init-db.sh
changed_when: false
run_once: yesTasks that call external APIs might not be idempotent. Document this:
- name: Deploy to production
uri:
url: https://api.example.com/deploy
method: POST
body_format: json
body:
version: "{{ app_version }}"
changed_when: falseDuring troubleshooting, you might need to run non-idempotent commands. That's fine—just don't commit them to your main playbooks.
Idempotency is the foundation of reliable infrastructure automation. It's what makes Ansible safe for production, enables scheduled automation, and allows you to treat your infrastructure as code.
The key takeaways:
changed_when and failed_when to control task behaviorWrite idempotent playbooks from the start. It takes slightly more effort upfront but saves enormous amounts of debugging and firefighting later. Your future self will thank you.