The promise of Ansible is simple: describe your desired state, run the playbook, and the system converges to that state. Run it again, nothing changes. That’s idempotency—and it’s harder to achieve than it sounds.

Here’s how to write playbooks that won’t surprise you on the second run.

The Problem: Commands That Lie

The command and shell modules are where idempotency goes to die:

1
2
3
# ❌ BAD: Always reports "changed", even when nothing changed
- name: Create database
  command: createdb myapp

This fails on the second run because the database already exists. Worse, it always shows as “changed” even when it shouldn’t run at all.

Pattern 1: Use creates/removes

For commands that create or remove files, tell Ansible:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ✅ GOOD: Only runs if the file doesn't exist
- name: Initialize database
  command: initdb /var/lib/postgres/data
  args:
    creates: /var/lib/postgres/data/PG_VERSION

# ✅ GOOD: Only runs if the file exists
- name: Remove legacy config
  command: rm -rf /etc/app/old_config
  args:
    removes: /etc/app/old_config

The creates argument means “skip this task if this path exists.” The removes argument means “skip this task if this path doesn’t exist.”

Pattern 2: Check Mode with changed_when

For commands where you need to determine if something actually changed:

1
2
3
4
5
6
7
8
9
- name: Check if user exists
  command: id appuser
  register: user_check
  ignore_errors: true
  changed_when: false  # This is just a check

- name: Create application user
  command: useradd -r -s /bin/false appuser
  when: user_check.rc != 0

Or combine check and action:

1
2
3
4
5
- name: Add SSH key to authorized_keys
  shell: |
    grep -q "{{ ssh_public_key }}" ~/.ssh/authorized_keys 2>/dev/null || echo "{{ ssh_public_key }}" >> ~/.ssh/authorized_keys
  register: result
  changed_when: "'{{ ssh_public_key }}' not in result.stdout"

Pattern 3: Prefer Native Modules

Ansible’s built-in modules handle idempotency for you:

1
2
3
4
5
6
7
8
9
# ❌ BAD: Shell command, not idempotent
- name: Install nginx
  shell: apt-get install -y nginx

# ✅ GOOD: Native module, inherently idempotent
- name: Install nginx
  apt:
    name: nginx
    state: present

Native modules for common operations:

OperationAvoidUse Instead
Package installshell: apt-get installapt, yum, dnf
User creationcommand: useradduser
File copyshell: cpcopy, template
Service controlshell: systemctlsystemd, service
Cron jobsshell: crontabcron

Pattern 4: Stateful Checks Before Actions

For complex operations, check state first:

1
2
3
4
5
6
7
8
- name: Get current Docker networks
  command: docker network ls --format '{{ '{{' }}.Name{{ '}}' }}'
  register: docker_networks
  changed_when: false

- name: Create application network
  command: docker network create --driver bridge app_network
  when: "'app_network' not in docker_networks.stdout_lines"

For database operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- name: Check if database exists
  postgresql_query:
    db: postgres
    query: SELECT 1 FROM pg_database WHERE datname = 'myapp'
  register: db_check

- name: Create database
  postgresql_db:
    name: myapp
    state: present
  when: db_check.rowcount == 0

Pattern 5: Handlers for Service Restarts

Don’t restart services unconditionally—use handlers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tasks:
  - name: Update nginx configuration
    template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Restart nginx  # Only triggers if file changed

handlers:
  - name: Restart nginx
    systemd:
      name: nginx
      state: restarted

Handlers only run when notified, and only run once even if notified multiple times.

Pattern 6: Atomic File Operations

For files that need to be updated atomically:

1
2
3
4
5
6
- name: Update application config
  template:
    src: config.yml.j2
    dest: /etc/app/config.yml
    backup: yes
    validate: /usr/bin/app --validate-config %s

The validate parameter runs before the file is moved into place. If validation fails, the original file remains untouched.

Pattern 7: Looping with Proper State Checks

When looping, ensure each iteration is idempotent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
- name: Create project directories
  file:
    path: "/var/www/{{ item }}"
    state: directory
    owner: www-data
    mode: '0755'
  loop:
    - app
    - app/static
    - app/uploads
    - logs

- name: Ensure required packages
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - python3
    - python3-pip
    - python3-venv

Pattern 8: Using Block for Conditional Groups

Group related tasks that should run together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
- name: Setup SSL certificates
  block:
    - name: Generate private key
      openssl_privatekey:
        path: /etc/ssl/private/app.key
        size: 4096

    - name: Generate CSR
      openssl_csr:
        path: /etc/ssl/certs/app.csr
        privatekey_path: /etc/ssl/private/app.key
        common_name: app.example.com

    - name: Generate self-signed certificate
      openssl_certificate:
        path: /etc/ssl/certs/app.crt
        privatekey_path: /etc/ssl/private/app.key
        csr_path: /etc/ssl/certs/app.csr
        provider: selfsigned
  when: use_ssl | bool

Testing Idempotency

Always run your playbook twice:

1
2
3
4
5
# First run - should make changes
ansible-playbook site.yml

# Second run - should report zero changes
ansible-playbook site.yml

If the second run shows changes, investigate. Either:

  • A task isn’t properly idempotent
  • Something external is modifying state between runs
  • You’re using changed_when incorrectly

The check_mode Trap

Check mode (--check) doesn’t guarantee idempotency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# This looks fine in check mode but breaks on real runs
- name: Get latest release
  uri:
    url: https://api.github.com/repos/owner/repo/releases/latest
  register: release
  check_mode: false  # Always runs, even in check mode

- name: Download release
  get_url:
    url: "{{ release.json.assets[0].browser_download_url }}"
    dest: /opt/app/release.tar.gz

The version could change between check mode and real execution. Design for deterministic state, not latest-whatever.

Key Takeaways

  1. Prefer native modules — they handle idempotency for you
  2. Use creates/removes — for commands that create or delete files
  3. Check state before acting — don’t assume, verify
  4. Use handlers for restarts — services shouldn’t bounce unnecessarily
  5. Test by running twice — the second run should show zero changes
  6. Be explicit about changed_when — tell Ansible what “changed” means

Idempotent playbooks are playbooks you can trust. They’re the difference between “run this carefully” and “run this whenever.”


Infrastructure code should be boring. If running your playbook twice is exciting, something’s wrong.