Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsPC HealthRecommendedCrashes, freezes, slowdowns? Check your PC nowSpot repairable issues before they interrupt work.Check PCHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
VGSources
Blog

How to Use Loops in Ansible Playbooks: Lists, Nested Data, and Retries

A practical guide to Ansible’s loop keyword, list and lookup requirements, loop_control, nested data filters, registered results, inventory iteration, migration from with_*, and per-item retries.
Length10 min Posted Quest giverVGSources Team
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Ansible’s loop keyword repeats one task for every element in a list. The current element is available as item by default, so a single task can install many packages, create many users, inspect many files, or process inventory hosts. The important constraints are that the loop expression must produce a list, nested data should usually be reshaped before iteration, and retry settings such as until apply independently to each loop item.

What an Ansible loop does

A task with loop is still one task definition, but Ansible executes it once for each value in the supplied list. During each execution, the current value is assigned to item. When the list is empty, the task has no iterations; when it has five elements, Ansible performs five task executions.

As an Amazon Associate I earn from qualifying purchases.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The simplest form is a literal list:

- name: Ensure each package is installed
  ansible.builtin.package:
    name: '{{ item }}'
    state: present
  loop:
    - nginx
    - postgresql
    - redis

Ansible runs the package task three times, once with each package name. The loop keyword is the preferred concise form for ordinary list iteration. The older with_* family remains available for compatibility and for cases where a lookup form is clearer.

Loop over a variable safely

Most playbooks keep data in variables rather than embedding it in a task. The variable used by loop must resolve to a list:

vars:
  users:
    - name: alice
      shell: /bin/bash
    - name: bob
      shell: /bin/zsh

tasks:
  - name: Create users
    ansible.builtin.user:
      name: '{{ item.name }}'
      shell: '{{ item.shell }}'
      state: present
    loop: '{{ users }}'

Each element here is a dictionary, so fields are accessed with expressions such as item.name. Dot notation is convenient when keys are simple identifiers; bracket notation, such as item['display-name'], is safer for keys containing punctuation or names that could collide with dictionary methods.

Why “value is not a list” appears

loop rejects a string. A lookup plugin can return a comma-separated string unless it is explicitly asked for a list, which commonly produces an error saying that the loop value is not a list. Prefer query(), because it always returns a list:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
- name: Run against every host in the inventory
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ query("inventory_hostnames", "all") }}'

The equivalent form with lookup() supplies wantlist=True:

- name: Run against every host in the inventory
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ lookup("inventory_hostnames", "all", wantlist=True) }}'

Use the same rule for any lookup plugin: verify whether it returns one value or a collection, and request list semantics when necessary. Do not fix the error by blindly splitting a string; a plugin may return values whose contents legitimately include commas.

Looping through dictionaries

A dictionary list is the usual shape for structured configuration. The task can use any fields from the current record:

- name: Create accounts with their primary groups
  ansible.builtin.user:
    name: '{{ item.name }}'
    group: '{{ item.primary_group }}'
    state: present
  loop: '{{ users }}'

If a field may be absent, decide explicitly whether to provide a default, skip the record, or fail validation. A conditional skip can be attached to the task:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
- name: Create only enabled accounts
  ansible.builtin.user:
    name: '{{ item.name }}'
    state: present
  loop: '{{ users }}'
  when: item.enabled | default(false)

Keep the data shape consistent. Mixing strings and dictionaries in one list forces every task to branch on the element type and makes failures harder to diagnose.

Control labels, variable names, and indexes

loop_control manages how a loop is displayed and which variables it exposes. A label keeps output readable without changing the value passed to the module:

- name: Show a concise user label
  ansible.builtin.debug:
    msg: '{{ item.name }}'
  loop: '{{ users }}'
  loop_control:
    label: '{{ item.name }}'

For nested tasks or records where item is ambiguous, assign a descriptive loop variable and, when needed, an index variable:

- name: Display users with their positions
  ansible.builtin.debug:
    msg: '{{ user_index }}: {{ user_record.name }}'
  loop: '{{ users }}'
  loop_control:
    loop_var: user_record
    index_var: user_index
Control Purpose Example
label Shows a concise value in task output while the full element remains available. label: '{{ item.name }}'
loop_var Renames the variable holding the current element. loop_var: user_record
index_var Provides the current zero-based index in a separate variable. index_var: user_index

Use a custom variable when a task includes another task file or when an outer and inner loop would otherwise both use item. Giving variables meaningful names makes conditions and registered-result processing much easier to read.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Shape nested data before looping

Ansible loops are simplest when each iteration receives one complete record. Instead of trying to nest several task-level loops, reshape the data with Jinja filters and then iterate once.

Preserve one-level flattening

The older with_items form implicitly flattens one level of nested lists. To preserve that behavior while migrating to loop, use flatten(1):

- name: Process values from the old with_items shape
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ items | flatten(1) }}'

The argument 1 matters: it removes exactly one nested list level rather than recursively flattening every list. Recursive flattening can destroy structure that later tasks need.

Generate a Cartesian product

Use product when every value in one list must be paired with every value in another list. Convert the result to a list before passing it to loop:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
- name: Show every user and database combination
  ansible.builtin.debug:
    msg: 'user={{ item.0 }}, database={{ item.1 }}'
  loop: '{{ users | product(databases) | list }}'

Each loop item is a two-element pair. For three input lists, the product produces three-element tuples, so the task must use the corresponding indexes.

Pair parallel lists with zip

zip combines elements at the same position in multiple lists. It is useful when one list contains names and another contains matching identifiers or settings:

- name: Assign each account its matching numeric ID
  ansible.builtin.debug:
    msg: '{{ item.0 }} receives ID {{ item.1 }}'
  loop: '{{ usernames | zip(user_ids) | list }}'

Make sure the source lists have the intended lengths. A short list limits the resulting pairs, so validate input when every source value is required.

Nested loops with include_tasks

When a loop includes a task file that has its own loop, the default variable name item would be reused. Ansible warns about the collision, and the inner loop can hide the outer value. Rename the outer variable with loop_control.loop_var:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
- name: Apply settings for each application
  ansible.builtin.include_tasks: application-settings.yml
  loop: '{{ applications }}'
  loop_control:
    loop_var: outer_application

Inside application-settings.yml, the inner task can use its normal item while still referring to outer_application:

- name: Create each application directory
  ansible.builtin.file:
    path: '/srv/{{ outer_application.name }}/{{ item }}'
    state: directory
    mode: '0755'
  loop: '{{ outer_application.directories }}'

This pattern is safer than relying on a warning or trying to recover an overwritten item. For many cases, flattening or building a combined list first is clearer than nesting task loops at all.

Retry every loop item with until

until repeats a task until its condition evaluates to true. retries limits the retry count and delay sets the wait between attempts:

- name: Wait for the web service
  ansible.builtin.command: systemctl is-active nginx
  register: service_status
  until: service_status.rc == 0
  retries: 5
  delay: 10

The current Ansible guide documents defaults of three retries and five seconds when until is used. Set both values explicitly when the timing matters. A registered result includes an attempts value, which lets later tasks report how many tries were needed.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When until and loop appear on the same task, Ansible evaluates the condition separately for each item. One item can succeed on its first attempt while the next item receives all configured retries:

- name: Wait for each endpoint to answer
  ansible.builtin.command: 'curl --fail --silent http://{{ item }}/health'
  register: endpoint_check
  until: endpoint_check.rc == 0
  retries: 5
  delay: 10
  loop: '{{ service_hosts }}'
  loop_control:
    label: '{{ item }}'

Use a condition based on the registered result for the current task. Do not test a variable that is set only after the loop has completed, because that would not describe the current attempt.

Use registered results from a loop

A looped task registered with register produces a result object containing a results collection. Each element contains the original loop value and the module’s return data. A file check can therefore be followed by a task that handles only missing files:

- name: Check required files
  ansible.builtin.stat:
    path: '{{ item }}'
  loop: '{{ paths }}'
  register: file_checks

- name: Report missing files
  ansible.builtin.debug:
    msg: '{{ item.item }} is missing'
  when: not item.stat.exists
  loop: '{{ file_checks.results }}'

In the second task, the outer item is one result record. Its original loop value is therefore available as item.item, while module fields such as item.stat.exists describe the check. A custom loop_var on the second task can make this less visually confusing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Loop over inventory hosts

Ansible exposes inventory groups as lists. To display every host in the inventory, use groups['all']:

- name: Show every inventory host
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ groups["all"] }}'

ansible_play_batch represents the hosts in the current play batch, which is useful when serial execution or failure handling means that the batch is smaller than the complete inventory:

- name: Show hosts in the current batch
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ ansible_play_batch }}'

For an inventory subset selected by a pattern, use the inventory-hostnames lookup through query():

- name: Show database hosts
  ansible.builtin.debug:
    msg: '{{ item }}'
  loop: '{{ query("inventory_hostnames", "databases") }}'

Remember that a host loop inside a play is different from the play’s normal host iteration. A task already runs once per host targeted by the play; looping over groups['all'] inside that task makes every play host process every inventory host. Use this deliberately, for example when building a cross-host report.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Migrate from with_* to loop

Most older with_* constructs can be expressed with loop, but migration is not always a text-only replacement. Preserve lookup behavior and flattening semantics.

Older form Loop equivalent Important detail
with_list loop Direct mapping when the input is already a flat list.
with_items loop: '{{ values | flatten(1) }}' Preserves its implicit single-level flattening.
with_indexed_items loop: '{{ values | flatten(1) }}' with loop_control.index_var Use the index variable instead of relying on a two-element indexed item shape.
Lookup-specific forms such as with_fileglob Often keep the original form The with_* syntax can express the lookup more clearly; a forced conversion may obscure intent.

After migration, check three things: whether the new expression returns a list, whether nested lists were flattened to the same depth, and whether the task still refers to the correct element fields. Run the playbook in a safe environment before changing a broad set of tasks.

Debug loops and diagnose common failures

When iteration behaves unexpectedly, inspect the actual value and increase Ansible’s verbosity:

ansible-playbook site.yml -v
ansible-playbook site.yml -vv

Use -v or -vv when you need module arguments, returned values, or retry details that are hidden at normal verbosity. A temporary debug task can reveal both the type and content of a variable:

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
- name: Inspect loop input
  ansible.builtin.debug:
    var: users
  • “The loop value must be a list.” The expression resolved to a string, undefined value, or another non-list type. Use query() or lookup(..., wantlist=True) for lookup results, and verify variable definitions.
  • Fields such as item.name fail. The current element is not a dictionary with that key. Inspect one element and make the input shape consistent.
  • The outer value disappears in an included task. Both loops are using item. Set an outer loop_var and refer to that name inside the included file.
  • Too many or too few iterations occur after migration. Compare the old and new flattening behavior. with_items flattened one nested level; plain loop does not.
  • A retry condition never succeeds. Check the registered variable and return code for the current item. Confirm that the command is idempotent enough to repeat and that delay and retries match the service’s startup time.
  • Output is unreadable. Add loop_control.label or rename the variable with loop_var so task output identifies the record being processed.

Choose the right looping approach

Situation Recommended approach Reason
Flat package, path, or name list loop Minimal syntax and direct item access.
List of dictionaries loop plus a descriptive label Fields remain structured and output stays readable.
Lookup output query() or lookup(..., wantlist=True) Guarantees list input.
Nested lists from an old task flatten(1) Matches with_items’s single-level flattening.
Every combination of two collections product(...) | list Creates a Cartesian product before iteration.
Parallel values at matching positions zip(...) | list Pairs corresponding elements.
Task file containing another loop include_tasks with an outer loop_var Prevents item collisions.
Eventually consistent service until with explicit retries and delay Retries independently for each loop item.
A lookup-specific legacy construct such as with_fileglob Often retain with_* The older syntax may communicate the lookup’s intent more clearly.

Practical loop checklist

  • Confirm that the expression after loop: resolves to a list, not a string.
  • Use query() for lookup plugins unless you have deliberately requested list output another way.
  • Keep each iteration’s data in a predictable shape, preferably one dictionary per record.
  • Use loop_control.label for readable output and loop_var for nested task files.
  • Shape nested data with flatten, product, or zip before the task runs.
  • When using until, register the task, set retry timing explicitly, and remember that the condition is evaluated per item.
  • Process a registered loop through its results collection; the original value is commonly found at item.item.
  • During a migration from with_*, preserve lookup semantics and the exact flattening depth.
  • Use ansible-playbook -v or -vv to inspect values, module results, and retry attempts.

For most new tasks, start with loop and a list. Add loop_control when names or output need clarification, reshape data before iteration when the input is nested, and combine until with explicit retry settings when each item may need time to become ready.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More quests from Patch Notes

  1. How to Set Up a RedM (RDR2) Server in 2026: License Key, txAdmin and server.cfg, Step by StepBlog11min
  2. Best RedM (RDR2) Server Hosting in 2026: Comparing Four Hosts on Slots, Memory and PriceBlog10min
  3. How to Host a Mindustry Server in 2026: server-release.jar, Port 6567 and the Commands That MatterBlog7min
Recommended PC Tool
Recommended PC Tool
Windows Errors? Fix Them Before They SpreadFree repair scan
Outdated Drivers Are Slowing You DownFree scan - exact matches

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.