Skip to content

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.

outer.yaml
- 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

process_team_broken.yaml
- 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:

outer.yaml
- name: "Process teams"
  ansible.builtin.include_tasks: process_team_fixed.yaml
  loop: "{{ teams }}"
  loop_control:
    loop_var: outer_item
process_team_fixed.yaml
- 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_var any time you’re nesting loops via include_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.
Last updated on