unit 5 · Logic

Loops, and the legacy with_*

One task, many items

essential · 6 min · linux + windows

you may want to read 12 · Playbook anatomy first — but nothing is locked

tl;dr

  • loop: runs one task once per list element; the current element lands in the item variable.
  • with_* is legacy, not deprecated — it still works and ansible-lint only warns. Write loop: in new code.
  • loop_control fixes noisy output: label picks what each status line prints.

why it matters Real playbooks manage lists — packages, users, firewall rules — and every legacy repo is full of with_items you must read correctly before you can modernise it.

Lesson 12 had you writing one task per action. Now count the actions in a real play: fifteen packages, six service accounts, a dozen firewall rules. You are not writing thirty-three tasks. loop: is a task keyword that takes a list and runs the task once per element. Inside each pass, the current element is available as a variable named item:

- name: Install the baseline packages
  ansible.builtin.dnf:
    name: '{{ item }}' # the current element, each pass
    state: present
  loop: # a plain YAML list
    - git
    - htop
    - tmux
one task, three passes

The run output shows one task header with three results under it, each independently ok or changed. The module’s logic is untouched — dnf still checks state before acting, so a second run reports ok three times. Loops repeat tasks, not work. (One aside: package modules accept a whole list in name: and resolve it in a single transaction, which is faster than looping. The loop here shows the shape; you’ll reach for it with modules that take one thing at a time.)

Lists of dicts

Real loops rarely run over bare strings. The workhorse pattern is a list of dictionaries, with dot access into the current one:

- name: Create the service accounts
  ansible.builtin.user:
    name: '{{ item.name }}' # dot access, like cfg_check.rc in lesson 15
    groups: '{{ item.groups }}'
  loop:
    - { name: 'deploy', groups: 'docker' }
    - { name: 'backup', groups: 'wheel' }
  loop_control:
    label: '{{ item.name }}' # status line shows this, not the whole dict
item.key reaches into the current dict

Without that loop_control: block, every per-item status line dumps the entire dict. With two keys that’s noise; with a ten-key dict holding a password hash, a real problem. label: chooses what prints instead. Two siblings are worth knowing. index_var: names a zero-based counter for the pass you’re on. loop_var: renames item itself — pointless now, essential in lesson 25, where nested loops would otherwise both write to item.

The with_* question

Open any Ansible repo more than a few years old and you won’t find loop:. You’ll find this:

old — don't write this · legacy — still works, prefer loop
- name: Install the baseline packages # pre-2.5 style
  ansible.builtin.dnf:
    name: '{{ item }}'
    state: present
  with_items: [git, htop, tmux]

with_items, with_dict, with_fileglob — a whole family, one keyword per data shape. Ansible 2.5 introduced loop: to replace the lot. One nuance to get exactly right, because half the internet gets it wrong: blogs routinely call with_items deprecated. Officially it is legacy — it still works, no removal has been announced, and ansible-lint flags it as a warning, not an error. The team policy that follows: write loop: in everything new, convert with_* when you’re already editing a task, and don’t raise a PR that only churns loop syntax.

Two combinations round this out. Put lesson 18’s when: on a looping task and it’s evaluated fresh for every element — when: item.create skips individual items, not the whole task. register: on a looping task stores a dict whose results key is a list, one result per pass. debug that when a single item misbehaves. What loops can’t do yet is react: change a config file for every item, and something still has to restart the service exactly once at the end. That’s handlers, next lesson.

at work, this sounds like

“Lint is flagging the with_items in that role.”
ansible-lint warns on legacy with_* loops — the role still runs fine, but convert to loop: when you touch those tasks.

self-check — recall, not a test

  1. 1 A role you inherited uses with_items everywhere. Is that broken, deprecated, or something else — and what does ansible-lint say about it?

  2. 2 You swap with_items for loop and a task that was fed a list of lists breaks. Why, and what is the fix?

  3. 3 Your loop over dicts prints an unreadable wall of keys per item. Which keyword cleans that up?

lab (optional — never required)

Loop ansible.builtin.user (or debug on localhost) over a list of three dicts, watch the noisy output, then add loop_control: label and rerun.

needs: The control node from lesson 5.

next up 20 · Handlers, notify & listen 6 min · unit 5