Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsLoops

loop, and when not to use one

Intermediate⏱ ~24 minansible-playbook

What you'll learn

  • Write loop over a list and over a dictionary with dict2items
  • Decide whether a loop should exist before deciding how to write it
  • Quantify the per-iteration cost of a loop against a single module call
  • Explain why one call with a list is more atomic than N calls with one item

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

The syntax is five minutes. The judgement is the lesson.

Almost every Ansible repository contains a version of this:

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

It works. It is also slower, noisier, less atomic and harder to troubleshoot than the version without a loop, and the version without a loop is shorter. So the first question about any loop is not how to write it. It is whether it should exist.

The syntax, briefly

loop takes a list and binds each element to item.

Read-only / Safethe three shapes you will actually use
# A literal list.
- name: Report each service
ansible.builtin.debug:
  msg: "{{ item }}"
loop:
  - nginx
  - postgresql

# A list from a variable. The braces are required here -
# loop takes a value, unlike when which takes an expression.
- name: Report each service
ansible.builtin.debug:
  msg: "{{ item }}"
loop: "{{ managed_services }}"

# A dictionary, via dict2items.
- name: Report each endpoint
ansible.builtin.debug:
  msg: "{{ item.key }} = {{ item.value }}"
loop: "{{ endpoints | dict2items }}"

Note the asymmetry with when: loop takes a value, so a variable reference needs {{ }}. when takes an expression, so it must not have them. That inconsistency is real, it catches everyone once, and the rule is simply that they are different keywords with different argument types.

dict2items turns {a: 1, b: 2} into [{key: a, value: 1}, {key: b, value: 2}]:

Read-only / Safedict2items, real output
$ ansible-playbook lc.yml
TASK [dict2items] **************************************************************
ok: [localhost] => (item=api) => {
  "msg": "api = https://api.example.com"
}
ok: [localhost] => (item=metrics) => {
  "msg": "metrics = https://metrics.example.com"
}

The cost, measured

Every iteration of a loop is a separate module invocation: build the payload, transfer it, start Python on the target, execute, parse JSON back.

Twenty iterations of stat against one path, versus one stat against that path, on ansible-core 2.21.3 over a local connection:

Read-only / Safetwenty iterations against one call
$ for p in loop1 loop2; do /usr/bin/time -f '%e s' ansible-playbook $p.yml; done
--- loop1.yml: stat, twenty iterations
3.09 s
3.11 s
--- loop2.yml: stat, one invocation
0.47 s
0.46 s

Nineteen extra iterations cost 2.6 seconds — roughly 138 ms each, with zero network latency and a warm cache. Over SSH to a host with 30 ms of round-trip latency, each iteration adds its own conversation on top of that.

Now scale it the way a fleet scales it. Ten packages, looped, across 400 hosts, at forks: 20:

  • Looped: 400 hosts x 10 iterations = 4,000 module invocations.
  • List: 400 hosts x 1 invocation = 400 module invocations.

The package manager is also doing ten separate transactions per host instead of one, and each of those has its own lock acquisition, metadata read and dependency resolution.

Atomicity: the argument that matters more than speed

Speed is the visible difference. Atomicity is the one that shows up in an incident review.

A loop of four package tasks where the third fails leaves the host with two packages installed and two not. The task is marked failed, the host drops out of the play, and the host is now in a state that neither the old configuration nor the new one describes. Re-running is safe if the module is idempotent — and it usually is — but the intermediate state existed, and if the play was a rolling deployment the host may have been serving traffic in it.

One package call with four names either resolves and installs the set or fails without installing any of it. There is no intermediate state to be caught in.

The same argument applies wherever the underlying tool has a transaction: package managers, firewall rule sets, database grants. Where the tool has no transaction — creating four directories — the argument reduces to speed, and speed alone is often not worth restructuring for.

When a loop is right

Plenty of situations genuinely need one:

The module takes one item, full stop. ansible.builtin.user creates one user. ansible.builtin.mount handles one mount point. No list parameter exists, so the loop is the mechanism.

Each item needs different parameters. A list of users with different shells and groups cannot collapse into one call, because the parameters differ per item, not just the name.

Configuration changea loop that has to exist
- name: Create the service accounts
ansible.builtin.user:
  name: "{{ item.name }}"
  group: "{{ item.group }}"
  shell: "{{ item.shell | default('/usr/sbin/nologin') }}"
  system: true
  state: present
loop: "{{ service_accounts }}"
loop_control:
  label: "{{ item.name }}"

You are iterating a registered result. Reading a results list, as in Part XIV lesson 6, is a loop over data rather than a loop of changes.

The list is short and the items are heterogeneous. Three config files with three different sources and destinations. A loop is clearer than three near-identical tasks, and clarity is a real reason.

A decision procedure

  1. Run ansible-doc <module> and read the parameter type. type: list means pass the list.
  2. If it takes one item, do the parameters differ per item? If not, ask whether the module has a plural sibling — file versus a templated list, lineinfile versus blockinfile.
  3. If the loop must exist, is the list bounded? A loop over a registered result can be any length, including 400. That is a different risk from a loop over four literal strings, and it is worth an assert on the length.
  4. Add a label. Which is the next lesson, and is not optional once a loop iterates anything containing a credential.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play installs ten packages with a loop over ansible.builtin.package across 400 hosts. What is the strongest argument for passing the list to a single task instead?

  2. Q2. Why does loop require {{ }} around a variable reference when when must not have them?

  3. Q3. Which of these are legitimate reasons for a loop to exist? Select all that apply.

  4. Q4. A loop over an empty list produces a task failure that makes the problem visible.

Passing score: 75%. Answers are checked in this browser.