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

OptionTypeDescription
loop_varstringRename the loop variable (default: item)
labelstringCustom label in task output (hides verbose data)
index_varstringVariable name for the loop index (0-based)
extendedboolEnable extended loop info (ansible_loop.*)
extended_allitemsboolInclude all items in extended info (default: true)
pausefloatSeconds 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:

VariableDescription
ansible_loop.indexCurrent iteration (1-based)
ansible_loop.index0Current iteration (0-based)
ansible_loop.firsttrue if first iteration
ansible_loop.lasttrue if last iteration
ansible_loop.lengthTotal number of items
ansible_loop.revindexIterations remaining (1-based)
ansible_loop.revindex0Iterations remaining (0-based)
ansible_loop.previtemPrevious iteration's value
ansible_loop.nextitemNext iteration's value
ansible_loop.allitemsAll 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

  1. Always use loop_var in include_tasks and include_role — the included file will have its own loops
  2. Use descriptive names — server, package, user not my_item
  3. Use label for complex objects — keeps output readable
  4. Use extended: true when you need loop position info (first/last/index)
  5. Lint check — ansible-lint warns about potential item collisions

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.