Nested Loops
This is the chapter with the most real “oh no, that’s a trap” potential in this entire section. Nested loops are genuinely useful — looping over teams, and looping over each team’s members within that — but Ansible’s default behavior for the inner loop’s variable name creates a collision that fails in a confusing way if you don’t know to watch for it.
Why “Nested Loops” In Ansible Need include_tasks
A single task can only have one loop:. Genuine nested looping — an outer loop, where each iteration itself runs a loop of its own — requires the outer loop to call a separate task file via ansible.builtin.include_tasks, and that included file has its own loop: inside it. Each outer iteration triggers the included file fresh, which then runs its own complete inner loop.
- name: Nested Loop Demo
hosts: servers
vars:
teams:
- name: backend
members: [alice, bob]
- name: frontend
members: [carol, dave]
tasks:
- name: "Process team: {{ item.name }}"
ansible.builtin.include_tasks: process_team_broken.yaml
loop: "{{ teams }}"The Collision: Two Loops, One item
Both the outer loop and the inner loop, left at their defaults, name their loop variable item. The included file’s own loop overwrites the outer loop’s item the moment it starts iterating — and once that happens, the outer team’s data is simply gone, with no way to reach it anymore from inside the inner loop.
Reproducing The Break
- name: "Print member with team name (BROKEN)"
ansible.builtin.debug:
msg: "{{ item }} is on the {{ item.name }} team"
loop: "{{ item.members }}"Trace through what actually happens here: when loop: "{{ item.members }}" is first evaluated, item still refers to the outer team dict — so this correctly resolves to ['alice', 'bob']. But the moment the inner loop actually starts running, item is reassigned to each member name in turn. By the time the task body runs, item is 'alice' — a plain string — and item.name on a string fails outright, since a string has no such field.
fatal: [ubuntu]: FAILED! => {"msg": "'alice' is undefined" }The error itself doesn’t obviously say “variable name collision” — it just looks like item mysteriously doesn’t have a .name anymore, which is exactly why this is worth knowing about before you hit it cold.
The Fix: loop_var
loop_control.loop_var renames the outer loop’s variable to something that won’t collide with the inner loop’s default item:
- name: "Process teams"
ansible.builtin.include_tasks: process_team_fixed.yaml
loop: "{{ teams }}"
loop_control:
loop_var: outer_item- name: "Print member with team name (FIXED)"
ansible.builtin.debug:
msg: "{{ item }} is on the {{ outer_item.name }} team"
loop: "{{ outer_item.members }}"ok: [ubuntu] => (item=alice) => {
"msg": "alice is on the backend team"
}
ok: [ubuntu] => (item=bob) => {
"msg": "bob is on the backend team"
}outer_item is never shadowed, since the outer loop no longer occupies the name item at all — the inner loop is free to use its own default, and both pieces of data stay reachable at the same time.
Best Practices
- Rename the outer loop’s variable with
loop_varany time you’re nesting loops viainclude_tasks— treat this as a required step, not an optional cleanup, the moment two loops are involved at once. - Rename the outer one, not the inner one, as a convention — it keeps the innermost, usually simplest loop reading naturally as
item, with the more structurally significant outer context given an explicit, descriptive name instead. - If a nested-loop error mentions a missing attribute on what should be a dictionary, suspect a naming collision first — this exact symptom is almost always the cause.