Sign in
← Ansible
14 min · medium · Lesson 5 of 12

Idempotency and why "bashible" is bad

changed vs ok, why command/shell aren't idempotent, creates/removes, changed_when, --check and --diff.

Ansible is valued because a playbook can run again and again: on a new machine it sets everything up, on an already configured one it breaks nothing. This property doesn't come from Ansible itself, but from how the tasks are written. In this lesson we'll look at the ok and changed statuses, why command and shell break repeat runs, and how to fix that with creates, removes and changed_when.

What idempotency is

Idempotency is a property of an operation where running it again with the same parameters doesn't change the result.

Run the playbook once or five times, the state of the system at the end is the same. There's no magic here. Every Ansible module first checks the current state and does something only if it differs from the desired one.

Task statuses: ok, changed, skipped, failed

After each task, Ansible prints a status for each host.

At the end of each run, Ansible prints PLAY RECAP, one line per host:

PLAY RECAP *********************************************************************
web-1                      : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

A well-written playbook shows changed=0 on the second run. All tasks are ok: the system is already in the desired state, nothing to change.

Normal modules are idempotent by default

The apt module checks: is the package already installed? Then ok. Not installed? It installs it and reports changed.

- name: install nginx
  apt:
    name: nginx
    state: present

The first run reports changed: nginx wasn't there, now it's installed. On the second run it's ok, the package is in place and there's nothing to do.

Same with other modules:

In the docs and in later lessons, modules are written with their full name: ansible.builtin.copy instead of copy. It's the same module, the full name just avoids confusing it with a same-named module from a collection.

Bashible: an antipattern

"Bashible" is when, instead of normal modules, the whole playbook is written with command and shell.

# BAD - antipattern
- name: install nginx
  shell: apt-get install -y nginx

- name: create directory
  command: mkdir -p /var/www/html

- name: copy config
  shell: cp /tmp/nginx.conf /etc/nginx/nginx.conf

The command and shell modules are not idempotent. Ansible doesn't know what your command does or what effect it has. So it:

  1. Runs it on every run, no exceptions (unless creates/removes is set).
  2. Marks the status changed if the command exits successfully, regardless of whether anything actually changed. This behavior is overridden with changed_when:.

What you lose:

When command/shell are still needed

Sometimes there's no specialized module. Then make command/shell idempotent by hand.

creates: — don't run if the file or path already exists:

- name: init database
  command: /opt/app/bin/init-db
  args:
    creates: /var/lib/app/.db_initialized

If /var/lib/app/.db_initialized already exists, the command doesn't run, the task reports ok. The command itself must create this file on first run, otherwise creates protects against nothing.

removes: — run only if the path exists:

- name: uninstall legacy agent
  command: /opt/legacy-agent/uninstall.sh
  args:
    removes: /opt/legacy-agent

The agent is gone, so the task is ok and the script doesn't run.

changed_when: — explicitly say when to count the task as changed:

- name: check app version
  command: /opt/app/bin/app --version
  register: app_version
  changed_when: false          # read-only, never changed
- name: update cache
  shell: update-cache.sh
  register: cache_result
  changed_when: "'updated' in cache_result.stdout"

In the second example, the status depends on what the command printed. To write it this way, you need to know exactly what your script prints when it changed something and when there was nothing to change.

when: — don't run at all if the condition doesn't match:

- name: configure firewall
  command: ufw allow 80
  when: ansible_os_family == "Debian"

ansible_os_family comes from the host facts, so the play needs fact gathering enabled (gather_facts, which is on by default).

The main advice: before writing shell, look for a module. copy, template, file and lineinfile work with files. apt, yum and dnf install packages, service and systemd manage services. For git there's git, for docker there are the community.docker collection modules.

Dry-run: --check and --diff

--check runs the playbook without making real changes. Ansible shows what would happen, but touches nothing.

ansible-playbook -i inventory.ini site.yml --check

--diff adds a line-by-line diff for files and templates: you can see exactly what would change in the config.

ansible-playbook -i inventory.ini site.yml --check --diff

Normal modules respect --check. The command and shell modules are skipped by default in check mode (skipping): Ansible can't predict what an arbitrary command will do. Exception: if a task has creates: or removes: set, in check mode Ansible checks the path and honestly says whether the command would run (changed) or not (ok). That's one more reason not to write bashible: in a dry-run you don't see what half the playbook will do.

Run 1 Run 2 apt: name=nginx state=present apt: name=nginx changed apt: name=nginx ok shell: apt-get install nginx shell: apt-get ... changed shell: apt-get ... changed (always!)

On the second run, apt understands that nginx is already installed and reports ok. shell runs again and again, because Ansible can't look inside the command.

Common mistakes

What you'll need for the tasks

In the labs, there's one lab environment: the app-1 container with ansible-core. Ansible there manages the same machine it runs on (ansible_connection=local in the inventory), so SSH isn't needed. The playbook lives in the lab's work/ folder, you edit it with your own editor, and run it inside the container with bash setup/run.sh.

In the next lesson we'll put together our first full playbook.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson ansible-basics-05-idempotency