Chapter 7: Control Flow
Topic 40

Loops

ConceptControl Flow

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.

The modern loop, the legacy alias, and the control knob
loop
Modern. Takes a plain list, binds each item to item. The current syntax for all new code.
with_items
Legacy. The direct predecessor — still works, but silently flattens nested lists. Recognize it, migrate it.
loop_control
Tames it. label 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.

One apt task installs the web tier's whole package list
- 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.

A looped register produces result.results, not a scalar — iterate it
- 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.

Common Mistakes
  • Reaching for with_items in new playbooks out of habit — it is legacy, and its silent list-flattening surprises you when a nested list collapses; use loop and flatten explicitly when you actually mean to.
  • Nesting a looped include_tasks whose inner tasks also use item, so the inner loop clobbers the outer — set loop_var: outer_item with loop_control to keep the two separate.
  • Looping a task that registers a result and then reading result.rc as if it were a scalar — a looped register produces result.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.label to print just the identifying field.
  • Trying to iterate a dict directly with loop: "{{ mymap }}" and getting a type error — convert it with dict2items first instead of fighting Jinja to walk a mapping.
Best Practices
  • Use loop: for all new loops and convert with_items opportunistically; keep with_* knowledge only to read and migrate old playbooks.
  • Convert dicts with dict2items (and back with items2dict) rather than fighting Jinja to iterate a mapping directly.
  • Set loop_control.label on any loop over structured items so the run output stays readable instead of scrolling full dicts.
  • Rename the loop variable with loop_control.loop_var whenever a looped task includes other looping tasks, to prevent item collisions between the levels.
  • Read a looped result through result.results, iterating it to inspect each iteration's rc and changed, never the top-level scalar keys.
Comparable tools Puppet resource 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_items silently flattens nested lists, while loop does not — so a nested list collapses under the old form
  • loop fans its iterations out to run in parallel across the host, while with_items always walks through them one at a time serially
  • with_items is rejected outright by the apt module and cannot be used to feed it package names at all
  • loop silently reverses the order of the supplied list, while with_items faithfully preserves the original sequence

How do you iterate a dict of usernames to settings in a loop?

  • Pass it through dict2items to get a list of {key, value} pairs, then read item.key and item.value
  • Feed the raw dict straight to loop:, which automatically walks every key and its matching value together for you
  • Use index_var to address each dict entry directly by its numeric position in the mapping
  • Convert the dict to a JSON string first, because loop only 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 .results list with one entry per iteration, each carrying its own rc, changed, and stdout
  • 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