When an Ansible task has both loop and register, what does the registered variable contain, and what is loop_control's label used for?
answer
- one entry per iteration, not one result
- the loop value rides along inside each entry
- top-level changed means any iteration changed
- output prints the whole item by default
- rename item when loops nest
basics
~20 sWith a loop, the registered variable holds a results list with one entry per iteration, each carrying its own item and module return values, rather than a single result. The loop_control label sets what Ansible prints for each iteration instead of the whole item.
solid answer
~50 sWithout a loop, `register: out` gives you the module's return values directly — `out.stdout`, `out.rc`. Add `loop`, and you instead get `out.results`, a list with one element per iteration; each element holds that iteration's return values plus `item`, the value it looped over. So you iterate downstream with `loop: "{{ out.results }}"` and reference `item.stdout`, not `out.stdout` — reaching for the latter is the classic undefined-attribute error. The top-level `out.changed` is true if *any* iteration changed. `loop_control` tunes the loop: `label` controls the one-line output per iteration, which matters both for readability (a loop over dictionaries otherwise dumps the whole structure) and for hygiene, since the default output can print a password field. `loop_var` renames `item`, required when an included file loops as well and the inner loop would shadow the outer one; `index_var` exposes the position; `pause` inserts a delay between iterations.
code
yaml · 18 lines- name: Check each service
ansible.builtin.command: "systemctl is-active {{ svc_name }}"
loop: "{{ services }}"
loop_control:
loop_var: svc_name
label: "{{ svc_name }}"
register: svc_status
changed_when: false
failed_when: false
- name: Report the ones that are down
ansible.builtin.debug:
msg: "{{ result.svc_name }} reported {{ result.stdout }}"
loop: "{{ svc_status.results }}"
loop_control:
loop_var: result
label: "{{ result.svc_name }}"
when: result.stdout != 'active'go deeper
Know that a looped task registers a results list rather than a single result, and that the loop value is available as item inside the task.
Explain the structure precisely: results holds one entry per iteration carrying item plus the module return values, top-level changed aggregates across iterations, and loop_control tunes naming and output.
Show the practical consequences — noisy or credential-leaking iteration output fixed with label plus no_log, nested loops needing loop_var, and looping a module that already accepts a list being a real performance mistake.
Own the convention for how per-host loop results become evidence: what a run should log versus suppress, and when iterating in a playbook should give way to letting the module or a data-driven structure do the work.
## register without a loop `register` stores a task's return value in a variable scoped to the current host. ```yaml - name: Read the app version ansible.builtin.command: /opt/app/bin/app --version register: version changed_when: false - ansible.builtin.debug: msg: "running {{ version.stdout }}" ``` The shape of the variable is whatever the module returned — `stdout`, `rc`, `stderr` for command-family modules, `dest` and `checksum` for file-family ones — plus the universal `changed`, `failed` and `skipped` fields. ## register with a loop: the results list Add `loop` and the shape changes. The registered variable no longer carries the module's fields at the top level; it carries `results`, a list with one entry per iteration: ```yaml - name: Check each service ansible.builtin.command: "systemctl is-active {{ item }}" loop: - nginx - postgresql register: svc failed_when: false changed_when: false - name: Report anything that is not active ansible.builtin.debug: msg: "{{ item.item }} is {{ item.stdout }}" loop: "{{ svc.results }}" when: item.stdout != 'active' ``` Three details do most of the work here: - Each element of `results` contains that iteration's module return values **and** `item`, the loop value it was called with. When you then loop over `svc.results`, the outer `item` is a result object, so the original value is `item.item` — ugly, but correct, and `loop_control.loop_var` is how you make it readable. - The top-level `svc.changed` is true if **any** iteration reported changed; `svc.failed` likewise. That is what makes `when: svc is changed` behave sensibly on a looped task. - Referring to `svc.stdout` after a loop raises an undefined-attribute error. This is the single most common mistake with looped registers. ## loop_control `loop_control` is a sibling keyword to `loop` that adjusts loop behaviour: ```yaml - name: Create service accounts ansible.builtin.user: name: "{{ account.name }}" uid: "{{ account.uid }}" loop: "{{ service_accounts }}" loop_control: loop_var: account label: "{{ account.name }}" ``` - **`label`** changes only the text Ansible prints for each iteration. By default it prints the entire item, so a loop over dictionaries produces walls of output — and if one of those keys is a credential, it lands in the CI log. A `label` naming just the identifying field fixes both. (`label` is display only; it does not affect what the module receives, and it is not a substitute for `no_log: true` when the *module arguments* themselves are sensitive.) - **`loop_var`** renames `item` for this loop. It is mandatory in practice when a task that loops includes a file whose tasks also loop, because the inner `item` would otherwise shadow the outer one. - **`index_var`** binds the zero-based iteration index to a variable. - **`pause`** inserts a delay in seconds between iterations, useful against a rate-limited API. - **`extended: true`** exposes `ansible_loop` with fields such as `first`, `last` and `index`. ## loop versus with_ and versus until `loop` is the modern form, introduced in Ansible 2.5; the older `with_items`, `with_dict`, `with_fileglob` family still works and is what you will meet in legacy playbooks. `with_items` flattens one level of nested lists while `loop` does not, which is the main behavioural difference when converting — use the `flatten` filter if you relied on it. Lookups still supply the data: `loop: "{{ query('fileglob', '/etc/app/conf.d/*') }}"`. Do not confuse `loop` with `until`. `until` with `retries` and `delay` re-runs the *same* task until a condition holds, which is a retry loop, not an iteration over data: ```yaml - name: Wait for the health endpoint ansible.builtin.uri: url: http://localhost:8080/health register: health until: health.status == 200 retries: 10 delay: 3 ``` ## A performance note worth having Looping a package installation one name at a time runs the module once per item. Most package and user modules accept a list directly, so `name: "{{ packages }}"` is a single transaction and dramatically faster than a loop over the same list. Preferring the module's own list support over a loop is the kind of detail that separates a working playbook from a fast one.
- Why does referencing register_var.stdout fail after a looped task?Because with a loop the registered variable holds `results`, a list of per-iteration results, and the module's own fields live inside each element rather than at the top level. You reach them as `register_var.results[0].stdout`, or by looping over `register_var.results` and using `item.stdout`. Only `changed`, `failed` and similar aggregate fields exist at the top.
- Besides readability, why does loop_control's label matter operationally?The default per-iteration output prints the whole item, so a loop over dictionaries of user or credential data dumps those fields into the run log and into CI output. A label naming only the identifying field keeps the log clean. It is display-only though — if the sensitive value is a module argument, you still need `no_log: true`.
- When is loop_var actually required rather than merely nicer?When a looping task includes tasks that also loop — for example `include_tasks` with a loop, where the included file's own loop rebinds `item` and shadows the outer value. Setting `loop_var` on the outer loop gives it a distinct name so the inner loop cannot clobber it. It is also the readable choice whenever the items are dictionaries.
- Would you loop a package module over a list of package names?Usually not. Package modules accept a list for `name`, so passing the whole list runs one transaction instead of invoking the module once per package — often the difference between seconds and minutes on a large list. Reserve the loop for cases where each iteration genuinely needs different arguments.
saying these in an interview costs you the question
- Reads register_var.stdout directly after a looped task
- Thinks register overwrites itself and keeps only the last iteration
- Believes loop_control label changes what the module receives
- Relies on label to hide a secret instead of no_log
- Loops a package module per name when it accepts a list