AnsibleXV · Conditionals and LoopsLoops
loop, and when not to use one
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
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.
# 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}]:
$ ansible-playbook lc.ymlTASK [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:
$ 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 sNineteen 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.
- 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
- Run
ansible-doc <module>and read the parameter type.type: listmeans pass the list. - If it takes one item, do the parameters differ per item? If not,
ask whether the module has a plural sibling —
fileversus a templated list,lineinfileversusblockinfile. - 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
asserton the length. - 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
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?
Q2. Why does loop require {{ }} around a variable reference when when must not have them?
Q3. Which of these are legitimate reasons for a loop to exist? Select all that apply.
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.