Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsLoops

loop_control and readable runs

Intermediate⏱ ~21 minansible-playbook

What you'll learn

  • Use label to keep a loop over structured data out of the run log
  • Rename the loop variable to avoid a collision in nested loops
  • Distinguish index_var from the extended ansible_loop index
  • Judge when pause is a legitimate control and when serial is the right tool

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

loop_control looks like a set of cosmetic options for tidying up run output. One of them is a disclosure control, and it defaults to the unsafe setting.

label: what the run log prints

By default, Ansible prints the whole item on every iteration. For a list of strings that is fine. For a list of dictionaries it is not:

Read-only / Safethe default: the entire item, every iteration
$ ansible-playbook lc.yml
TASK [Without label the whole dict prints] *************************************
ok: [localhost] => (item={'name': 'svc_backup', 'token': 'REPLACE_ME_1'}) => {
  "msg": "configuring svc_backup"
}
ok: [localhost] => (item={'name': 'svc_deploy', 'token': 'REPLACE_ME_2'}) => {
  "msg": "configuring svc_deploy"
}

The task never referenced item.token. It printed anyway, because the default label is the whole item and the whole item includes every key.

label replaces what is printed:

Read-only / Safewith label: only what you chose
$ ansible-playbook lc.yml
TASK [With label only the name prints] *****************************************
ok: [localhost] => (item=svc_backup) => {
  "msg": "configuring svc_backup"
}
ok: [localhost] => (item=svc_deploy) => {
  "msg": "configuring svc_deploy"
}
Read-only / Safethe syntax
- name: Configure the service accounts
ansible.builtin.debug:
  msg: "configuring {{ item.name }}"
loop: "{{ accounts }}"
loop_control:
  label: "{{ item.name }}"

loop_var: avoiding a collision

item is a variable like any other, at task-var precedence. Nested loops therefore collide: an outer loop binds item, an inner one binds item, and the inner wins.

The classic case is a task file included in a loop, which itself contains a loop:

Read-only / Saferenaming the outer loop variable
# site.yml
- name: Configure each site
ansible.builtin.include_tasks: configure_site.yml
loop: "{{ sites }}"
loop_control:
  loop_var: site
  label: "{{ site.name }}"

# configure_site.yml - free to use item for its own loop
- name: Deploy each vhost file for this site
ansible.builtin.template:
  src: "{{ item }}.j2"
  dest: "/etc/nginx/sites-available/{{ item }}"
  mode: '0644'
loop: "{{ site.vhosts }}"

The rule worth adopting: any loop on include_tasks or include_role renames its loop variable. You cannot know what the included file loops over, and the collision is silent — the inner value simply wins and the outer task appears to process the wrong data.

Renaming also improves readability at no cost. site.name says what it is; item.name requires the reader to scroll up.

index_var and extended

Two ways to know where you are in a loop, and they count differently.

Read-only / Safeindex_var is zero-based
$ ansible-playbook lc.yml
TASK [index_var and a renamed loop_var] ****************************************
ok: [localhost] => (item=svc_backup) => {
  "msg": "index 0 -> svc_backup"
}
ok: [localhost] => (item=svc_deploy) => {
  "msg": "index 1 -> svc_deploy"
}
Read-only / Safeextended gives ansible_loop, and its index is one-based
$ ansible-playbook lc.yml
TASK [extended gives ansible_loop] *********************************************
ok: [localhost] => (item=svc_backup) => {
  "msg": "1/2 first=True last=False revindex=2"
}
ok: [localhost] => (item=svc_deploy) => {
  "msg": "2/2 first=False last=True revindex=1"
}

index_var is zero-based. ansible_loop.index is one-based. Both appear in the same output above — index 0 and index 1 from index_var, 1/2 and 2/2 from ansible_loop. Mixing them is a classic off-by-one.

extended provides index, index0, revindex, revindex0, first, last, length, previtem, nextitem and allitems. The useful ones in practice:

KeyUse
ansible_loop.firstDo something only on the first item
ansible_loop.lastFlush, reload or report only at the end
ansible_loop.lengthInclude a progress count in a message
ansible_loop.allitemsThe whole list, from inside an iteration

allitems carries a memory cost, because every iteration’s result holds a copy of the entire list. extended_allitems: false turns just that off while keeping the rest of extended — worth setting for a loop over anything large.

pause: a rate limit, not a rollout control

pause inserts a delay in seconds between iterations.

Configuration changea legitimate use of pause
- name: Register each host with the inventory API
ansible.builtin.uri:
  url: "https://cmdb.example.com/api/v1/hosts"
  method: POST
  body_format: json
  body:
    hostname: "{{ item.name }}"
  headers:
    Authorization: "Bearer {{ cmdb_token }}"
  status_code: [200, 201]
loop: "{{ host_records }}"
loop_control:
  label: "{{ item.name }}"
  pause: 2
no_log: true

That is what pause is for: an external service with a rate limit, or a device that needs settling time between operations.

The other reason to be wary: pause is dead time inside the run. pause: 2 over 50 items is 100 seconds per host, serialised, during which the connection is held open and the play cannot proceed. On a large fleet that is a real cost, and it is worth asking whether the rate limit could be handled by batching the request instead.

A default worth adopting

Read-only / Safethe shape to write by default
- name: Deploy each application config
ansible.builtin.template:
  src: "{{ app.template }}"
  dest: "/etc/{{ app.name }}/config.yml"
  mode: '0640'
loop: "{{ applications }}"
loop_control:
  loop_var: app
  label: "{{ app.name }}"

Two lines of loop_control on every loop over structured data. It costs nothing, it keeps the run log readable, and it means the day somebody adds a secret to that structure the run log does not immediately publish it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task loops over a list of dictionaries, each containing name and token. The task only ever references item.name. What appears in the run log by default?

  2. Q2. A play needs to restart a service across forty hosts without taking them all down at once. Is loop_control.pause the right tool?

  3. Q3. Which statements about loop_control are accurate? Select all that apply.

  4. Q4. A label expression that references a key missing from one item in the list will fail the task, even though the module itself would have succeeded.

Passing score: 75%. Answers are checked in this browser.