Skip to content

8. Loops, conditionals and error handling

- name: Install packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- curl
- jq

loop: replaced the with_* family in Ansible 2.5. with_items, with_dict, with_fileglob and the rest still work and you will see them everywhere, but new code should use loop with a filter where needed:

with_items → loop (add | flatten(levels=1) if nested)
with_dict → loop: "{{ d | dict2items }}"
with_fileglob → loop: "{{ query('fileglob', '*.conf') }}"
with_nested → loop: "{{ a | product(b) | list }}"
with_together → loop: "{{ a | zip(b) | list }}"
with_sequence → loop: "{{ range(1, 11) | list }}"

One important behavioural difference: with_items flattens nested lists automatically; loop does not. A list of lists passed to loop iterates over the inner lists rather than their elements, which is a confusing five minutes. Add | flatten(levels=1) when converting.

# slow: N separate package-manager transactions
- ansible.builtin.package:
name: "{{ item }}"
loop: "{{ packages }}"
# fast: ONE transaction
- ansible.builtin.package:
name: "{{ packages }}"
state: present

Several modules take a list directly — package, apt, dnf, yum, pip, user (via groups), file in some forms. Passing the list is dramatically faster because it is one operation on the target rather than N. On a fifty-package install this is the difference between four minutes and fifteen seconds.

- name: Create application users
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
loop: "{{ app_users }}"
loop_control:
label: "{{ item.name }}" # print this, not the whole dict
index_var: user_index
pause: 2 # seconds between iterations
extended: true # gives ansible_loop.first/last/index

label is the one you want by default. Without it, a loop over a list of dictionaries prints the entire dictionary for every iteration, which floods the output — and if any of those dictionaries contains a password, prints it too.

A registered loop does not give you result.stdout. It gives you result.results, a list:

- ansible.builtin.command: "systemctl is-active {{ item }}"
loop: [nginx, postgresql, redis]
register: services
changed_when: false
failed_when: false
- ansible.builtin.debug:
msg: "{{ services.results | selectattr('rc','ne',0) | map(attribute='item') | list }}"
# → the services that are not active

.results plus selectattr is the standard way to act on the subset that failed or changed, and it is much better than ignore_errors plus hope.

when: ansible_facts['os_family'] == 'Debian'
when: # a LIST is AND
- ansible_facts['os_family'] == 'Debian'
- app_enabled | bool
- install_path is defined
when: a or b # OR needs to be explicit

A list of conditions is AND. There is no list form of OR; write it with or in a single expression, and add parentheses when mixing.

Three specifics that catch people:

when on a loop is evaluated per item, not once for the whole loop:

- ansible.builtin.user:
name: "{{ item.name }}"
loop: "{{ users }}"
when: item.enabled | bool # per item

when on a block applies to every task inside it, which is the cleanest way to guard a section.

when on import_tasks versus include_tasks behaves differently, and that is the next section.

This is the distinction that produces the most surprising behaviour in Ansible, and it is worth getting straight once.

import_tasks / import_role / import_playbook
STATIC. Processed when the playbook is PARSED,
before anything runs.
include_tasks / include_role / include_vars
DYNAMIC. Processed when the task is REACHED,
during the run.

The consequences:

import_* include_*
─────────────────────────────────────────────────────
uses a variable set NO — the variable YES
at runtime in its does not exist
file name at parse time
`when:` applies to EVERY task inside the include
(copied down) itself only
tags apply to every task inside the include
(copied down) itself only
--list-tasks shows yes no
the contents
can be used in a loop NO YES
handlers inside are yes yes
available

The two rules that follow:

Use import_* by default. Static is more predictable, shows up in --list-tasks, and lets tags work the way people expect.

Use include_* when you need runtime behaviour — a filename built from a variable, a loop, or something conditional on a fact that does not exist until the run starts.

And the tag trap, stated plainly:

- ansible.builtin.include_tasks: setup.yml
tags: [setup]

Running --tags setup runs the include statement, and then every task inside setup.yml runs unconditionally, because the tag was never applied to them. To fix it:

- ansible.builtin.include_tasks:
file: setup.yml
apply:
tags: [setup]
tags: [setup]

apply: pushes the keyword down into the included tasks. It works for tags, when, become and others. With import_tasks, none of this is necessary — the tag propagates automatically.

A block groups tasks, and gives you error handling.

- name: Deploy the application
block:
- name: Stop the service
ansible.builtin.service: {name: app, state: stopped}
- name: Unpack the new release
ansible.builtin.unarchive:
src: "app-{{ version }}.tar.gz"
dest: /opt/app
- name: Start the service
ansible.builtin.service: {name: app, state: started}
rescue:
- name: Roll back
ansible.builtin.command: /opt/app/bin/rollback
- name: Report the failure
ansible.builtin.fail:
msg: "Deploy failed at {{ ansible_failed_task.name }}"
always:
- name: Make sure the service is running whatever happened
ansible.builtin.service: {name: app, state: started}
become: true
when: deploy_enabled | bool
tags: [deploy]
block the tasks to try
rescue runs ONLY if a task in the block failed
always runs regardless — success, failure, or rescue

Inside rescue you get two variables:

ansible_failed_task the task object that failed
ansible_failed_result its full result

A successful rescue clears the failure. The host continues the play as if nothing went wrong, which is exactly right for a rollback and exactly wrong if you wanted the run to fail — add a fail: at the end of the rescue if the host should still be marked failed.

Keywords on a block apply to every task inside it, which is the neatest way to set become, when or tags for a group of related tasks.

- name: Wait for the API to come back
ansible.builtin.uri:
url: "https://{{ inventory_hostname }}/healthz"
status_code: 200
register: health
until: health.status == 200
retries: 30
delay: 5
changed_when: false

until retries the task until the condition is true. Defaults are retries: 3 and delay: 5.

until requires register, because the condition is written against the registered result. And note the arithmetic: retries: 30 with delay: 5 is a two-and-a-half minute ceiling. Write the timeout you actually want rather than accepting the defaults.

For the specific case of waiting for something to become available, the purpose-built modules are better:

- ansible.builtin.wait_for: # a port, a file, a string in a file
port: 5432
timeout: 120
delay: 5
- ansible.builtin.wait_for_connection: # can Ansible itself reach the host?
timeout: 300 # the right one after a reboot

Five keywords, from narrowest to broadest:

# 1. This task's failure does not count
- ansible.builtin.command: /opt/app/bin/optional-step
ignore_errors: true # rarely right — chapter 5
# 2. Redefine what failure means
- ansible.builtin.command: /opt/app/bin/status
register: r
failed_when: r.rc not in [0, 2]
# 3. Unreachable is not the same as failed
- ansible.builtin.ping:
ignore_unreachable: true
# 4. One host failing stops EVERY host immediately
- hosts: databases
any_errors_fatal: true
# 5. Abort once too many hosts have failed
- hosts: webservers
serial: 10
max_fail_percentage: 20

any_errors_fatal: true belongs on plays where partial application is dangerous — a schema migration across a database cluster, a coordinated configuration change. Without it, Ansible removes the failed host and carries on with the rest, leaving you with a fleet in two states.

max_fail_percentage with serial is the safety net for a rolling deployment. Chapter 11 puts them together properly. Note that the percentage is evaluated per batch, not across the whole play.

- name: Preconditions
ansible.builtin.assert:
that:
- ansible_facts['os_family'] in ['Debian', 'RedHat']
- ansible_facts['memtotal_mb'] | int >= 2048
- app_version is defined
- app_version is version('2.0.0', '>=')
fail_msg: "Host does not meet requirements for {{ app_name }}"
success_msg: "Preconditions satisfied"
tags: [always]

A role that asserts its preconditions fails in the first second with a clear message, rather than in the ninth minute with a confusing one. Put an assert at the top of any role that has requirements, tag it always so it runs even under --tags, and you will save yourself the class of bug where a playbook half-applies to a machine it was never meant for.

  • loop replaced with_*, and unlike with_items it does not flatten — add | flatten(levels=1) when converting.
  • Pass lists to package, apt, dnf and pip directly instead of looping. One transaction instead of N.
  • loop_control: label: stops loops printing whole dictionaries — including any passwords in them.
  • A registered loop gives you .results, a list. .results | selectattr(...) is how you act on the subset that failed.
  • A list of when conditions is AND. There is no list form of OR.
  • import_* is static, include_* is dynamic. Import by default; include when you need a runtime filename, a loop, or a runtime condition.
  • Tags and when do not propagate into an include_* — use apply: — but they do propagate into an import_*.
  • block/rescue/always gives you try/catch/finally. A successful rescue clears the failure; add fail: if you did not want that.
  • until needs register, and retries: 3, delay: 5 is a 15-second ceiling. Use wait_for_connection after a reboot.
  • any_errors_fatal: true where partial application is dangerous. max_fail_percentage is evaluated per serial batch.
  • Assert preconditions at the top of a role, tagged always.

Next: roles and collections — how to package all of this so somebody else can use it, and what changed when Ansible split itself apart in 2.10.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source