Introduction
When Ansible runs a loop, it assigns each element to the variable item. This works fine for simple tasks — but breaks when you have nested loops, included tasks with their own loops, or roles that also use item. The loop_control directive solves this by letting you rename the loop variable and control loop output.
The Problem: Variable Collisions
Nested Include with Loop
# main.yml
- name: Configure servers
ansible.builtin.include_tasks: configure.yml
loop:
- web
- db
# configure.yml
- name: Install packages
ansible.builtin.dnf:
name: "{{ item }}" # COLLISION! This 'item' is from the outer loop
state: present
loop:
- nginx
- curl
The inner loop's item overwrites the outer loop's item, causing unexpected behavior.
The Fix: loop_control
loop_var — Rename the Loop Variable
# main.yml — rename outer loop variable
- name: Configure servers
ansible.builtin.include_tasks: configure.yml
loop:
- web
- db
loop_control:
loop_var: server_type
# configure.yml — 'item' is now safe to use
- name: Install packages for {{ server_type }}
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- nginx
- curl
Now server_type holds the outer loop value, and item works normally in the inner loop.
loop_control Options
| Option | Type | Description |
|---|---|---|
loop_var | string | Rename the loop variable (default: item) |
label | string | Custom label in task output (hides verbose data) |
index_var | string | Variable name for the loop index (0-based) |
extended | bool | Enable extended loop info (ansible_loop.*) |
extended_allitems | bool | Include all items in extended info (default: true) |
pause | float | Seconds to pause between iterations |
Practical Examples
label — Clean Output for Complex Data
Without label, Ansible prints the entire object on each iteration:
# NOISY OUTPUT — dumps entire dict per iteration
- name: Create users
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
shell: "{{ item.shell }}"
comment: "{{ item.comment }}"
loop: "{{ users }}"
Output: TASK [Create users] => (item={'name': 'alice', 'groups': ['sudo', 'docker'], 'shell': '/bin/bash', 'comment': 'Alice Smith - Engineering'})
With label:
- name: Create users
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
shell: "{{ item.shell }}"
loop: "{{ users }}"
loop_control:
label: "{{ item.name }}"
Output: TASK [Create users] => (item=alice) — much cleaner.
index_var — Loop Counter
- name: Create numbered config files
ansible.builtin.template:
src: worker.conf.j2
dest: "/etc/myapp/worker-{{ idx }}.conf"
loop:
- { port: 8001, threads: 4 }
- { port: 8002, threads: 4 }
- { port: 8003, threads: 2 }
loop_control:
index_var: idx
label: "worker-{{ idx }}"
extended — Full Loop Metadata
- name: Process items with progress
ansible.builtin.debug:
msg: >-
Processing {{ item }}
({{ ansible_loop.index }}/{{ ansible_loop.length }},
{{ 'last item' if ansible_loop.last else 'more to go' }})
loop:
- alpha
- beta
- gamma
loop_control:
extended: true
Extended variables available:
| Variable | Description |
|---|---|
ansible_loop.index | Current iteration (1-based) |
ansible_loop.index0 | Current iteration (0-based) |
ansible_loop.first | true if first iteration |
ansible_loop.last | true if last iteration |
ansible_loop.length | Total number of items |
ansible_loop.revindex | Iterations remaining (1-based) |
ansible_loop.revindex0 | Iterations remaining (0-based) |
ansible_loop.previtem | Previous iteration's value |
ansible_loop.nextitem | Next iteration's value |
ansible_loop.allitems | All items in the loop |
pause — Rate Limiting
- name: Restart services one at a time
ansible.builtin.systemd:
name: "{{ service }}"
state: restarted
loop:
- nginx
- redis
- postgresql
loop_control:
loop_var: service
pause: 10 # Wait 10 seconds between restarts
Nested Roles with Loops
# playbook.yml
- name: Deploy applications
hosts: all
tasks:
- name: Deploy each app
ansible.builtin.include_role:
name: deploy_app
loop:
- { name: frontend, port: 80 }
- { name: api, port: 8080 }
- { name: worker, port: 9090 }
loop_control:
loop_var: app # Role can safely use 'item' internally
label: "{{ app.name }}"
Multiple Levels of Nesting
# Three-level nesting
- name: Configure environments
ansible.builtin.include_tasks: setup_env.yml
loop: [dev, staging, prod]
loop_control:
loop_var: env_name
# setup_env.yml
- name: Configure services in {{ env_name }}
ansible.builtin.include_tasks: setup_service.yml
loop: [web, api, db]
loop_control:
loop_var: service_name
# setup_service.yml
- name: Apply configs for {{ env_name }}/{{ service_name }}
ansible.builtin.template:
src: "{{ item }}.conf.j2"
dest: "/etc/{{ service_name }}/{{ item }}.conf"
loop:
- main
- logging
Best Practices
- Always use
loop_varininclude_tasksandinclude_role— the included file will have its own loops - Use descriptive names —
server,package,usernotmy_item - Use
labelfor complex objects — keeps output readable - Use
extended: truewhen you need loop position info (first/last/index) - Lint check —
ansible-lintwarns about potentialitemcollisions
Related Articles
- Ansible Best Practices Guide
- Ansible Debug Module Guide
- Ansible-Lint Guide
- Jinja2 Conditionals in Ansible
Conclusion
Use loop_control whenever your loops interact with included files or roles. The key options: loop_var prevents variable collisions, label keeps output clean, index_var gives you a counter, and extended provides full loop metadata. The rule is simple — if a task with a loop includes another file, rename the outer loop variable.