Loops
A loop runs one task repeatedly over a list — install five packages, create three users, template two vhosts — instead of copying the task five times. Modern Ansible uses loop:; the older with_items and the whole with_* family still work and fill legacy playbooks, but new code should use loop with lookups when it needs the fancier sources.
The output is one task line per item, which is why labeling the iterations matters as the list grows. A loop over forty package names with no label produces forty noisy lines; a loop with a clean label produces forty readable ones. The mechanics are simple — the discipline is in keeping the output and the variables under control.
loop versus the Legacy with_*
loop: takes a plain list and is the current syntax. with_items is its direct predecessor and flattens nested lists — a behavior loop deliberately does not copy, which is the one real difference that bites during migration. The rest of the family maps onto loop plus a filter: with_dict becomes loop over dict2items, with_together becomes loop over zip. The exception is the lookup-backed forms like with_fileglob, which the docs say to leave as they are rather than rewrite into a loop over a lookup. Recognize the old forms in inherited playbooks; write loop in new ones.
loopitem. The current syntax for all new code.with_itemsloop_controllabel trims the per-iteration output; loop_var renames item to dodge collisions in nested loops.Looping Over Lists and Dicts
loop: "{{ packages }}" walks a list with the current value bound to item. Iterating a mapping needs a conversion first: loop: "{{ users | dict2items }}" turns a dict into a list of {key, value} pairs, so a map of usernames to settings becomes something you can walk, reaching item.key and item.value inside.
- name: Install the web-tier packages ansible.builtin.apt: name: "{{ item }}" state: present loop: "{{ web_packages }}" loop_control: label: "{{ item }}"
For the apt module specifically, you could pass the whole list to name: and install in one transaction — but the looped form is the general pattern that works for any module, and it is the shape you will reuse for users, files, and templates.
loop_control
loop_control is how you tame a loop. label controls what prints per iteration — show item.name instead of a forty-line dict — keeping the run output legible. loop_var renames item to something else, which you need when an included task also loops and would otherwise clobber the outer item. index_var exposes the zero-based position for the occasional case where you need to know which iteration you are on.
Registering a Looped Result
register: on a looped task does not capture a single result — it captures a .results list, one entry per iteration, each with its own rc, changed, and stdout. The scalar keys you would read on a non-looped task are not at the top level; you iterate result.results afterward to inspect each pass. Reading result.rc on a looped register reads nothing, and the surprise is silent.
- name: Check each site's health endpoint ansible.builtin.command: curl -sf http://localhost/{{ item }}/health loop: "{{ site_names }}" register: health changed_when: false - name: Report any site that failed its check ansible.builtin.debug: msg: "{{ item.item }} returned rc {{ item.rc }}" loop: "{{ health.results }}" when: item.rc != 0
Each entry in health.results carries an item key naming which iteration it came from, so you can report exactly which site failed rather than a bare exit code with no context.
The Larkspur Cases
The web tier shows all three shapes at once. apt loops over the tier's package list to install in one task. A loop over users | dict2items walks a map of app environment keys into app.env. And a single loop over a list of site definitions templates both the web1 and web2 vhosts from one task — add a third site to the list and the third vhost appears with no new task. That is the payoff: the playbook describes the shape of the data, and the loop scales the work to fit it.
- Reaching for
with_itemsin new playbooks out of habit — it is legacy, and its silent list-flattening surprises you when a nested list collapses; useloopand flatten explicitly when you actually mean to. - Nesting a looped
include_taskswhose inner tasks also useitem, so the inner loop clobbers the outer — setloop_var: outer_itemwithloop_controlto keep the two separate. - Looping a task that registers a result and then reading
result.rcas if it were a scalar — a looped register producesresult.results, a list, and the scalar keys simply are not there to read. - Letting a loop over large dicts dump each full item to the screen, burying the real output under page after page — set
loop_control.labelto print just the identifying field. - Trying to iterate a dict directly with
loop: "{{ mymap }}"and getting a type error — convert it withdict2itemsfirst instead of fighting Jinja to walk a mapping.
- Use
loop:for all new loops and convertwith_itemsopportunistically; keepwith_*knowledge only to read and migrate old playbooks. - Convert dicts with
dict2items(and back withitems2dict) rather than fighting Jinja to iterate a mapping directly. - Set
loop_control.labelon any loop over structured items so the run output stays readable instead of scrolling full dicts. - Rename the loop variable with
loop_control.loop_varwhenever a looped task includes other looping tasks, to preventitemcollisions between the levels. - Read a looped result through
result.results, iterating it to inspect each iteration'srcandchanged, never the top-level scalar keys.
each and array expansion
Chef plain Ruby loops inside recipes
Terraform for_each / count — declarative graph expansion, not ordered per-host iteration
Knowledge Check
What is the one behavioral difference between with_items and loop that matters during migration?
with_itemssilently flattens nested lists, whileloopdoes not — so a nested list collapses under the old formloopfans its iterations out to run in parallel across the host, whilewith_itemsalways walks through them one at a time seriallywith_itemsis rejected outright by theaptmodule and cannot be used to feed it package names at allloopsilently reverses the order of the supplied list, whilewith_itemsfaithfully preserves the original sequence
How do you iterate a dict of usernames to settings in a loop?
- Pass it through
dict2itemsto get a list of{key, value}pairs, then readitem.keyanditem.value - Feed the raw dict straight to
loop:, which automatically walks every key and its matching value together for you - Use
index_varto address each dict entry directly by its numeric position in the mapping - Convert the dict to a JSON string first, because
looponly ever accepts serialized JSON as its input
What does loop_control.label solve?
- It controls what prints per iteration, so a loop over large dicts shows one identifying field instead of the full item
- It filters the loop down so it processes only the labeled subset of items and silently skips all the rest
- It renames the loop internally so that two separate loops running in the same play do not accidentally share state with each other
- It pauses the loop and waits for interactive confirmation before running each labeled iteration in turn
You register: result on a looped task. What shape does result take?
- A
.resultslist with one entry per iteration, each carrying its ownrc,changed, andstdout - A single flat scalar value holding only the outcome of the very last iteration that ran
- A single merged result where the keys of every iteration are combined and summed together into one dict
- Nothing usable at all — a task driven by a loop simply cannot be registered
You got correct