Conditionals, loops and handlers in depth
when expressions and registered results, loop and its controls, until with retries, changed_when and failed_when, and handler ordering with flush_handlers.
Conditionals and registered results
- name: Only on Debian family with enough memory
ansible.builtin.apt:
name: postgresql
state: present
when:
- ansible_os_family == "Debian"
- ansible_memtotal_mb >= 2048
- not ansible_check_mode
- name: Find the existing config
ansible.builtin.stat:
path: /etc/app/config.yml
register: app_config
- name: Write a default only when none exists
ansible.builtin.copy:
src: config.default.yml
dest: /etc/app/config.yml
force: false
when: not app_config.stat.exists
- name: Report what happened, from the registered shape
ansible.builtin.debug:
msg: "config existed: {{ app_config.stat.exists }}"| Registered key | Meaning | Common use |
|---|---|---|
changed | The module reported a change | Deciding whether to act |
failed | The task failed | Always run and inspect |
rc | Return code of a command | Deciding success yourself |
stdout / stdout_lines | Captured output | Parsing a version |
results | Per-item results from a loop | Finding which item failed |
A conditional is evaluated per host, so the same play can take different branches on different machines. That is a feature, and also the reason a play must never assume every host ran the same tasks.
Loops and their controls
- name: Install a list of packages
ansible.builtin.package:
name: "{{ packages }}"
state: present
# passing the whole list is faster than a loop: one transaction
- name: Create several users with per-item data
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups | default([]) }}"
shell: /bin/bash
loop:
- { name: alice, groups: [sudo, docker] }
- { name: bob, groups: [developers] }
loop_control:
label: "{{ item.name }}" # keeps the output readable
pause: 1 # be gentle with an API call per item
- name: Retry an unreliable command until it succeeds
ansible.builtin.uri:
url: https://api.internal/health
status_code: 200
register: health
until: health.status == 200
retries: 10
delay: 3
changed_when: false # a health probe never changes anything
- name: Treat a nonzero return code as success
ansible.builtin.command: /usr/local/bin/app --check-config
register: check
failed_when: check.rc not in [0, 4]
changed_when: falseloopwith a list of module arguments is the modern form;with_itemsis the older equivalent and also flattens nested lists.- A loop over an existing package list passes the list directly to
name:so the package manager does one transaction instead of N. changed_when: falseon any read-only command keeps a report honest; a probe that always reports changed trains people to ignore change counts.failed_whenreplaces the module's own failure logic, so keep the default condition in the expression rather than discarding it.
Handlers and their ordering
- name: Configure and start the app
hosts: web
become: true
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: Restart app
ansible.builtin.systemd:
name: app
state: restarted
daemon_reload: true
tasks:
- name: Deploy the site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-enabled/site.conf
notify: Reload nginx
- name: Deploy the unit file
ansible.builtin.template:
src: app.service.j2
dest: /etc/systemd/system/app.service
notify: Restart app
# run a reload now instead of at the end of the play,
# because a later task depends on the service being reloaded
- name: Flush pending handlers before continuing
ansible.builtin.meta: flush_handlers
- name: Verify the app responds
ansible.builtin.uri:
url: http://127.0.0.1:8080/health
status_code: 200⚠️
A handler runs once per play, at the end, and only if a task that notified it reported changed. Two tasks notifying the same handler produce one restart. Reordering or renaming a handler breaks the notification silently — Ansible warns about an unknown handler name, so read the output rather than skimming it.
FAQ
Why did my handler not run?
Three usual reasons: the notifying task reported no change, the handler name does not exactly match, or a later task failed before the handlers phase. Use
flush_handlers when a subsequent task depends on the change.Should I use <code>loop</code> or pass a list to the module?
Pass the list when the module supports it, which for packages and users it usually does. A loop is for when each item needs different arguments or its own notification.
Related
Variables, facts and Jinja2 templating Error handling, blocks and rescue
Last refreshed 2026-09-18.