The Scenario
Your deployment playbook has been running without issues for weeks. You add a new role that loops over a list of users โ standard stuff. Then the entire play crashes mid-run with:
The loop variable 'item' is already in use. You should set the `loop_var` value in the `loop_control` option for the task to something different to avoid this error.
Nothing changed in the outer playbook. The role looks clean. So what broke?
Why This Happens
Ansible defaults to item as the loop variable for every loop directive. Two tasks can't share that name at the same time.
When a looping task calls include_tasks (or a role that internally loops), the outer and inner tasks both grab for item. Ansible 2.5+ detects this collision and stops rather than silently overwriting the outer value โ which would produce wrong results with no error message to point to.
The setup that triggers it almost every time:
- Outer playbook loops over environments or users
- That loop calls
include_tasksorinclude_role - The included file or role also contains a
loop
# playbook.yml โ outer loop
- name: Configure users
include_tasks: setup_user.yml
loop: "{{ users }}"
# setup_user.yml โ inner loop (CONFLICT)
- name: Add SSH keys for user
authorized_key:
user: "{{ item.name }}"
key: "{{ item.key }}"
loop: "{{ item.ssh_keys }}" # 'item' already taken by outer loop!
Ansible sees item claimed by the outer loop, tries to reuse it in the inner loop, and aborts.
Quick Fix
Add loop_control with a custom loop_var to the outer loop, the inner loop, or both. Renaming the outer loop is usually cleaner โ you own that task directly and can pick a descriptive name.
Option 1: Rename the outer loop variable
# playbook.yml
- name: Configure users
include_tasks: setup_user.yml
loop: "{{ users }}"
loop_control:
loop_var: user_item
# setup_user.yml โ 'item' is now free for the inner loop
- name: Add SSH keys for user
authorized_key:
user: "{{ user_item.name }}"
key: "{{ key }}"
loop: "{{ user_item.ssh_keys }}"
loop_control:
loop_var: key
Option 2: Rename the inner loop variable
If you don't own the outer loop โ say it's inside a third-party role โ rename the inner one instead:
- name: Add SSH keys for user
authorized_key:
user: "{{ item.name }}"
key: "{{ ssh_key }}"
loop: "{{ item.ssh_keys }}"
loop_control:
loop_var: ssh_key
Permanent Fix for Roles
Roles are built to be reused. Any role that uses the default item will eventually conflict with a caller that also loops. That's a bug waiting to happen.
The fix: always give your role's loop variables a role-specific name. A common convention is to prefix with the role name or a short abbreviation โ vhost instead of item, pkg instead of item, and so on.
# roles/nginx_vhost/tasks/main.yml
- name: Create vhost configs
template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ vhost.name }}.conf"
loop: "{{ nginx_vhosts }}"
loop_control:
loop_var: vhost # role-specific, won't clash
label: "{{ vhost.name }}"
The label field is a useful bonus. Without it, Ansible prints the full dict on every iteration โ often a wall of JSON. With it, you see only what you defined, like vhost.name. Much easier to scan in CI logs.
Dealing with include_role Inside a Loop
Looping over include_role works the same way. Add loop_control to the include_role task itself:
- name: Deploy application per environment
include_role:
name: deploy_app
loop: "{{ environments }}"
loop_control:
loop_var: env
label: "{{ env.name }}"
Then check inside deploy_app โ none of its tasks should use env as their own loop variable, or you'll be right back where you started.
Three Levels Deep
Three nested loops is uncommon, but complex provisioning roles can get there. Each level needs a distinct variable name โ no exceptions:
# Level 1
- include_tasks: per_region.yml
loop: "{{ regions }}"
loop_control:
loop_var: region
# per_region.yml โ Level 2
- include_tasks: per_host.yml
loop: "{{ region.hosts }}"
loop_control:
loop_var: host
# per_host.yml โ Level 3
- name: Apply firewall rules
ufw:
rule: allow
port: "{{ port }}"
loop: "{{ host.allowed_ports }}"
loop_control:
loop_var: port
Verify the Fix
Before running against real hosts, do a syntax check and a dry run:
# Catches variable name collisions in static analysis
ansible-playbook playbook.yml --syntax-check
# Dry run โ verbose output shows your loop variable names in action
ansible-playbook playbook.yml --check -v
With verbose output, task labels should now show your custom loop variable instead of item. If you set label, that string appears rather than the raw object. A clean run looks like this:
TASK [Add SSH keys for user] ***
ok: [server1] => (item=deploy_key)
ok: [server1] => (item=backup_key)
No collision error, custom names visible โ you're done.
Quick Reference
loop_control:
loop_var: my_var # rename 'item' to 'my_var'
label: "{{ my_var.name }}" # cleaner log output
index_var: idx # optional: loop index (0, 1, 2...)
pause: 1 # optional: seconds between iterations
One rule to remember: every loop inside a role should define a loop_var. It's one extra line. It's also the difference between a playbook that runs at noon and one that fails at 2 AM with no obvious cause.

