8. Loops, conditionals and error handling
Ansible · 8. Loops, conditionals and error handling- name: Install packages ansible.builtin.package: name: "{{ item }}" state: present loop: - nginx - curl - jqloop: 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.
Do not loop the package module
Section titled “Do not loop the package module”# slow: N separate package-manager transactions- ansible.builtin.package: name: "{{ item }}" loop: "{{ packages }}"
# fast: ONE transaction- ansible.builtin.package: name: "{{ packages }}" state: presentSeveral 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.
loop_control
Section titled “loop_control”- 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/indexlabel 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.
Loops and register
Section titled “Loops and register”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.
Conditionals
Section titled “Conditionals”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 explicitA 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 itemwhen 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.
include versus import
Section titled “include versus import”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 availableThe 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.
Blocks
Section titled “Blocks”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 rescueInside rescue you get two variables:
ansible_failed_task the task object that failed ansible_failed_result its full resultA 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.
Retries
Section titled “Retries”- 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: falseuntil 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 rebootFailure control
Section titled “Failure control”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: 20any_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.
Asserting instead of hoping
Section titled “Asserting instead of hoping”- 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.
What to take from this chapter
Section titled “What to take from this chapter”loopreplacedwith_*, and unlikewith_itemsit does not flatten — add| flatten(levels=1)when converting.- Pass lists to
package,apt,dnfandpipdirectly 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
whenconditions 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
whendo not propagate into aninclude_*— useapply:— but they do propagate into animport_*. block/rescue/alwaysgives you try/catch/finally. A successfulrescueclears the failure; addfail:if you did not want that.untilneedsregister, andretries: 3, delay: 5is a 15-second ceiling. Usewait_for_connectionafter a reboot.any_errors_fatal: truewhere partial application is dangerous.max_fail_percentageis evaluated perserialbatch.- 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.