Handlers, Loops and Conditionals

K
Kai··5 min read

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 is loop. When you need a complex loop (over dicts, combining lists...), there are filters like dict2items, product — but a loop over 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 play RUNNING HANDLER [Restart nginx] → changed.
  • Run 2 (config unchanged): the copy task is ok → no notify → handler doesn't run (PLAY RECAP changed=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 (like catch). After rescue, the play continues instead of stopping.
  • always — always runs whether the block succeeds or fails (like finally) — 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 for command/shell in 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.