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 keyMeaningCommon use
changedThe module reported a changeDeciding whether to act
failedThe task failedAlways run and inspect
rcReturn code of a commandDeciding success yourself
stdout / stdout_linesCaptured outputParsing a version
resultsPer-item results from a loopFinding 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: false
  • loop with a list of module arguments is the modern form; with_items is 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: false on any read-only command keeps a report honest; a probe that always reports changed trains people to ignore change counts.
  • failed_when replaces 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.

Variables, facts and Jinja2 templating Error handling, blocks and rescue

Last refreshed 2026-09-18.