Skip to content

Lesson 5 of 9 · 22m

Loops, Handlers, and Conditionals Without the Traps

loop vs the deprecated with_* forms, when clauses that actually evaluate the way you think, and the handler lifecycle that decides whether your service reload happens.

The two questions that expose Ansible experience: "why did your handler not run?" and "why is your when never true?" Both are lifecycle misunderstandings, not syntax errors — the code parses, it just doesn't mean what you think.

loop: one syntax to learn, the rest to forget

- name: Create service accounts
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  loop:
    - { name: deploy, groups: "docker,sudo" }
    - { name: backup, groups: "backup" }

loop replaced the with_items/with_dict/with_fileglob zoo (they're lookup-plugin calls in disguise — they still work, but new code should use loop). loop_control: label: "{{ item.name }}" keeps the log readable when item is a fat dict, and until/retries/delay handle retry loops:

- name: Wait for health endpoint
  ansible.builtin.uri:
    url: "http://{{ inventory_hostname }}:8080/healthz"
    status_code: 200
  register: health
  retries: 12
  delay: 10
  until: health.status == 200

when: Jinja truthiness, not Python

- name: Only on Debian family
  ansible.builtin.apt: { name: htop, state: present }
  when: ansible_facts['os_family'] == 'Debian'

- name: Restart if config drifted
  ansible.builtin.service: { name: app, state: restarted }
  when: config_result is changed

when evaluates a Jinja2 expression per task, per host — a conditional applies to each host independently, which is how "only reboot hosts that need it" works in one task. Traps: never wrap when in {{ }} (it's already Jinja); prefer the is changed / is failed tests on registered results; and a string "false" from -e is truthy — pipe it through | bool.

Handlers: the notify lifecycle

Handlers run once, at the end of the play, only if notified by a task that reported changed — notify five times, it still runs once, in definition order rather than notification order.

handlers:
  - name: Reload nginx
    ansible.builtin.service: { name: nginx, state: reloaded }
    listen: web config changed     # tasks can notify the topic instead of the name

The failure mode that bites: if a later task fails on that host, pending handlers never fire — the config changed but the service still runs the old one, and the next run sees no change so it never reloads. Fixes: meta: flush_handlers where you need the reload mid-play, or force_handlers: true (--force-handlers) so notified handlers run despite failures.

Field notes

  • changed on command/shell is a lie unless you set changed_when — fix it or accept noisy runs.
  • listen: topics let several handlers respond to one notification — "web config changed" can reload nginx and purge the CDN.
  • include_tasks is dynamic and can be looped; import_tasks is resolved at parse time and can't take loop at all.