Fix Ansible 'The loop variable item is already in use' Error in Nested Loops

intermediate๐Ÿ”ง Ansible2026-05-07| Ansible 2.5+, any OS (Linux/macOS), commonly triggered inside roles with include_tasks or nested loop structures

Error Message

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.
#ansible#loop#loop_control#loop_var#role

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_tasks or include_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.

Related Error Notes