Loops: Lists, Dictionaries and item

Scenario: A task loops over a service catalogue and fails with "'str object' has no attribute 'port'". The catalogue is a dictionary; the loop is treating it like a list.

New words, in plain English

loop repeats one task for each element of a collection, exposing the current element as item.

Over a list, item is the element itself. Over a dictionary, you cannot loop directly - a dictionary is not a sequence of the things you want. Piping it through dict2items converts {api: {port: 8001}} into [{key: 'api', value: {port: 8001}}], after which item.key is the name and item.value.port is the setting.

That conversion is the fix for the scenario. Looping a dictionary without it iterates over the keys as strings, so item is 'api' and item.port fails exactly as described.

For readability with complex data, loop_control: { label: "{{ item.key }}" } replaces a screenful of dumped structure in the output with a single meaningful name per iteration.

A performance note that matters: many modules accept a list directly. package: name={{ packages }} installs everything in one transaction; package: name={{ item }} with a loop makes a separate call per package and is dramatically slower. Prefer the list form when the module supports it.

Analogy: A list is a queue of people - you serve each one in turn. A dictionary is a filing cabinet: you cannot serve a cabinet, you have to open the drawers first. dict2items opens the drawers and lays the folders out in a row.

A worked example

# The data
services:
  api:    { port: 8001, enabled: true }
  worker: { port: 8002, enabled: true }
  debug:  { port: 8009, enabled: false }

# WRONG - looping a dict gives you plain key STRINGS
- ansible.builtin.file:
    path: "~/srv/services/{{ item }}"
    state: directory
  loop: "{{ services }}"
  when: item.enabled          # 'str object' has no attribute 'enabled'

# RIGHT
- name: Create a directory per enabled service
  ansible.builtin.file:
    path: "~/srv/services/{{ item.key }}"
    state: directory
    mode: "0755"
  loop: "{{ services | dict2items }}"
  when: item.value.enabled
  loop_control:
    label: "{{ item.key }}"

when inside a loop is evaluated per item, not once for the task. That is what lets a single loop skip only the disabled entries, and it is a genuinely useful property.

register inside a loop behaves differently too: instead of a single result, you get a results list with one entry per iteration, each carrying its own changed, rc and stdout. Code that expects result.stdout after a loop will fail; it needs result.results[0].stdout or its own loop.

The older with_items, with_dict and with_nested keywords still work and appear in a lot of existing code, so you will need to read them. Write new code with loop plus the appropriate filter - dict2items, subelements, product - which is clearer about what is actually happening.

Tip: loop_control: { label: ... } is the difference between output you can read and output you scroll past. Set it on any loop over structured data.
Goal: Put this to work in the ansible-loops-conditionals lab. Open /labs/ansible, pick ansible-loops-conditionals, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.