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.
- ok — the state is already right. Ansible changed nothing.
- changed — Ansible brought the host to the desired state: it was different, now it's fixed.
- skipping — the task wasn't run: the
when:condition didn't match, or the module can't work in--checkmode. - failed — the task failed. For this host the run stops, other hosts continue (unless configured otherwise).
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:
copycompares the checksum of the file on the host with the source. It matches, sook. If someone edited the file on the host by hand,copyrestores it and reportschanged.file state=directorycreates the folder if it's missing. It exists, sook.templaterenders the template and compares it with the file on the host. It matches, sook.lineinfilewatches a single line in a file: it adds the line only if it isn't there yet.service state=startedchecks whether the service is running and starts it only if it's stopped.
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:
- Runs it on every run, no exceptions (unless
creates/removesis set). - Marks the status
changedif the command exits successfully, regardless of whether anything actually changed. This behavior is overridden withchanged_when:.
What you lose:
- No state check.
apt-get install -y nginxruns even if nginx is already installed. - You'll never see
changed=0on the second run. - Side effects on every run.
cpoverwrites the config even if it hasn't changed. Then the task gets an unnecessarychanged, and if anotifyis attached to it, nginx restarts for no reason. - Commands that append to a file (
echo ... >> file) add another copy of the line on every run.
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.
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
changed_when: falseon a task that actually changes something. The counter looks nice, but the command still runs on every pass, and you stop seeing real changes.creates:points at a path the command doesn't create. Then the command runs every time, as ifcreatesweren't there. First figure out which file or folder appears after the first run.echo ... >> fileinshell. Every run appends the line again. For a single line in a file there'slineinfile, for a whole file there'scopy.--checkshowedchanged=0, and it looks fine.command/shelltasks withoutcreates/removesare skipped in check mode, they're not in that counter. Look at theskipped=line inPLAY RECAP.- Checking idempotency with one run. You need two in a row: the first
sets things up, the second should give
changed=0.
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.
ansible-playbook -i inventory.ini site.ymland thePLAY RECAPline (ok,changed,failed);- the
file,copy,lineinfilemodules instead ofmkdir,cpandecho >>; creates:andchanged_when:oncommand,register:for a command's output;- the habit of running the playbook twice in a row and watching
changed.
In the next lesson we'll put together our first full playbook.
Interview-style questions
Interview questions on this topic: 3
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
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.
Tell the agent: use MCP izi, give me lesson ansible-basics-05-idempotency
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress