Handlers, Loops and Conditionals
So far the playbook runs tasks sequentially, unconditionally. In practice you need: to repeat a task over many values, to run a task only when a condition is true, to react to changes (restart a service when config changes), and to handle errors. These are the four flow-control tools: loop, when, handlers, block/rescue.
Loop: repeat a task over many values
Instead of writing three tasks to install three packages, use loop:
- name: Install multiple packages
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- git
- vim
- htop
{{ item }} takes each value in loop in turn. The run log shows one line per iteration, and idempotency still applies to each item:
TASK [Install multiple packages] ******
changed: [lab] => (item=git)
ok: [lab] => (item=vim) # vim already installed → ok, not changed
changed: [lab] => (item=htop)
A loop can also iterate a list of dicts (install a package with a version, create multiple users with attributes...):
- name: Create multiple users
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.group }}"
loop:
- { name: alice, group: dev }
- { name: bob, group: ops }
Old docs use
with_items; the modern form isloop. When you need a complex loop (over dicts, combining lists...), there are filters likedict2items,product— but aloopover a single list is enough for most cases.
when: run conditionally
when decides whether a task runs, based on a variable/fact (Article 6):
- name: Only run on Amazon Linux
ansible.builtin.debug:
msg: "Running on {{ ansible_distribution }}"
when: ansible_distribution == "Amazon"
If the condition is false, the task is skipped (Article 4). when is extremely useful for writing cross-platform playbooks (install with dnf on RedHat, apt on Debian — based on ansible_distribution), or running a task based on a previous task's result (register in Article 6):
- name: Check whether file exists
ansible.builtin.stat:
path: /opt/app/installed
register: app_check
- name: Only install if not present
ansible.builtin.command: /opt/install.sh
when: not app_check.stat.exists
Combine conditions with and/or, or a list of when (all conditions must be true):
when:
- ansible_distribution == "Amazon"
- ansible_distribution_version == "2023"
Handlers: react to changes
This is a signature Ansible pattern. The problem: when you change a service's config file, you need to restart the service — but only when the config actually changed, not every playbook run. A handler solves this: a special task that runs only when notify'd.
tasks:
- name: Edit nginx config
ansible.builtin.copy:
content: "server_tokens off;\n"
dest: /etc/nginx/conf.d/hardening.conf
notify: Restart nginx # notify the handler if this task is CHANGED
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
The mechanism:
Task "Edit nginx config"
├─ config CHANGED (changed) ──► notify "Restart nginx"
└─ config NOT changed (ok) ──► no notify
... other tasks run on ...
── END of play ──► notified handler runs ONCE
Two important properties: a handler only runs if the task that notified it reports changed, and it runs once at the end of the play (even if multiple tasks notify it). Real run log:
- Run 1 (new config): the copy task is
changed→ notify → at end of playRUNNING HANDLER [Restart nginx] → changed. - Run 2 (config unchanged): the copy task is
ok→ no notify → handler doesn't run (PLAY RECAPchanged=0).
This is idempotency applied to restarting: nginx restarts only when the config actually changes. Without a handler, you'd blindly restart on every run — needless disruption.
block / rescue / always: error handling
By default, a task failure makes the play stop on that host. When you need clean error handling (like try/catch), use block + rescue + always:
- name: Might fail
block:
- name: Command that might fail
ansible.builtin.command: /opt/risky-migration.sh
rescue:
- name: Recover on failure
ansible.builtin.debug:
msg: "Migration failed — rolled back"
always:
- name: Always run (cleanup)
ansible.builtin.debug:
msg: "Cleaning up whether it succeeded or failed"
block— a group of tasks that run normally.rescue— runs if a task in the block fails (likecatch). After rescue, the play continues instead of stopping.always— always runs whether the block succeeds or fails (likefinally) — suited for cleanup.
The run log (the block intentionally fails and rescue catches it) shows in PLAY RECAP: failed=0 rescued=1 — the failure was "rescued", the play didn't stop.
Besides block/rescue, there's task-level error control:
ignore_errors: true(ignore the error, don't overuse),failed_when:(define when something counts as a failure),changed_when:(define when something counts as changed — useful forcommand/shellin Article 5).
Tags: selective runs (preview, deep dive in Article 13)
You attach tags to a task/play to run part of a playbook:
- name: Install package
ansible.builtin.dnf: { name: nginx, state: present }
tags: [install]
Then ansible-playbook site.yml --tags install runs only tasks tagged install. Handy when a playbook is long but you only want to run the config part. Article 13 covers tags in depth alongside operational techniques.
🧹 Cleanup
Sample code is in the nghiadaulau/ansible-series repo, directory 07-flow-control.
Wrap-up
Four flow-control tools: loop repeats a task over a list (idempotency still applies per item); when runs a task conditionally on a variable/fact/result (register); handlers react to changes via notify — running only when a task reports changed, once at the end of the play (the "restart on config change" pattern); block/rescue/always handle errors like try/catch/finally. Plus tags for selective runs.
The playbook now has a full toolset, but to scale to a large system you need to organize reusable code. Article 8: roles — how to package a playbook, variables, templates, and handlers into a reusable unit.