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- the modern way to repeat a task over a collection. Replaces the olderwith_*keywords.item- the variable holding the current element of the loop.- List - an ordered collection:
[8001, 8002]. - Dictionary - key-to-value pairs:
{api: {port: 8001}}. dict2items- the filter that converts a dictionary into a list of{key, value}pairs so it can be looped over.
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, pickansible-loops-conditionals, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.