Ansible Loops Explained with loop, until, retries and delay

Learn Ansible loops with loop, loop_control, register results, when per item, until retries and delay, and when to use wait_for instead of looping.

Published

Updated

Read time 14 min read

Reviewed byDeepak Prasad

Ansible loop and until retries on Rocky Linux 10

Ansible loops let you run one task many times without copying the same YAML block. You repeat over packages, users, files, services, firewall ports, or config entries while keeping the playbook readable.

This guide covers loop (iterate over data), loop_control (labels, custom variable names, pauses), registered loop output, when per item, dictionary iteration, and until with retries and delay. It does not re-teach lists and dictionaries, full register variable internals, or deep Jinja filter tutorials—those have dedicated pages.

Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.

What are Ansible Loops?

A loop repeats one task for multiple values. Ansible evaluates the task once per item in the list (or per generated item from a filter such as dict2items).

Normal loops use loop:. Retry loops use until:—the same task runs again until a condition is true or attempts run out. The official conditionals guide treats these as separate patterns: loop for iteration, until for polling and retries.


When Should You Use Loops?

Reach for a loop when the task logic is identical but the data changes:

  • Install or remove several packages
  • Create multiple users or groups
  • Deploy a set of files or templates
  • Open several firewall ports
  • Apply one service task per config entry

Before you loop, check whether the module already accepts a list. Some modules process a list in one call, which is often faster than loop. The dnf module docs note that when you use loop, each package is handled individually; passing the list to name is more efficient when every item needs the same action.

Ansible already loops over hosts in hosts:. Loop over inventory data such as groups['web'] or ansible_play_batch only when one task must inspect another host list—not for normal per-host work. See the inventory guide for host and group patterns. For play-level task ordering, see playbook structure.


Basic loop Syntax

The task runs once for each element in the list. The current value is available as item unless you rename it with loop_control.

The YAML excerpts below are fragments inside a play—indentation is relative to the play (hosts, vars, tasks), not column zero of the file.

yaml
- name: Basic loop demo
  hosts: lab
  gather_facts: false
  tasks:
    - name: Echo each number
      ansible.builtin.command: "echo {{ item }}"
      loop:
        - 1
        - 2
        - 3
      changed_when: false

Create a small demo playbook:

bash
cat > ~/ansible-project/playbooks/loop-basic-demo.yml << 'EOF'
---
- name: Basic loop demo
  hosts: lab
  gather_facts: false
  tasks:
    - name: Echo each number
      ansible.builtin.command: "echo {{ item }}"
      loop:
        - 1
        - 2
        - 3
      changed_when: false
EOF

Run it:

bash
cd ~/ansible-project
ansible-playbook playbooks/loop-basic-demo.yml

Sample output:

output
TASK [Echo each number] ********************************************************
ok: [rocky2] => (item=1)
ok: [rocky2] => (item=2)
ok: [rocky2] => (item=3)

Each (item=…) line is one loop iteration on rocky2.


Ensure loop Input is a List

loop expects a list. If a variable may be undefined, empty, or produced by a lookup, make sure the final value is a list before looping.

Use default([]) for optional lists:

yaml
loop: "{{ optional_packages | default([]) }}"

When a lookup can return a string, prefer query() or lookup(..., wantlist=True) so Ansible receives a list, not one long string. The official loop guide warns that loop does not accept a bare string—errors such as Invalid data passed to loop, it requires a list usually mean the input needs list-safe handling.

NOTE
For file and environment lookups (file, env, pipe, and similar), query() and lookup() behave differently on the control node—query() is usually the safer default when you need a list for loop. That distinction is advanced; for RHCE-style loops over play vars and inventory data, a plain list is enough.

Loop Over a List

Store the list in a variable when the same data is reused:

yaml
- name: Loop over package list
  hosts: lab
  vars:
    package_names:
      - tree
      - which
  tasks:
    - name: Install packages from list
      ansible.builtin.dnf:
        name: "{{ item }}"
        state: present
      loop: "{{ package_names }}"
      become: true

The same pattern works for file paths, service names, or user names—only the module and item usage change.

When every item gets the same module arguments, prefer a list on the module option:

yaml
- name: Install multiple packages in one module call
  ansible.builtin.dnf:
    name: "{{ package_names }}"
    state: present
  become: true

One task, one module invocation, less overhead than looping.


Loop Over a List of Dictionaries

Use a list of dictionaries when each item has multiple fields:

yaml
- name: Loop over dict list
  hosts: lab
  vars:
    users:
      - name: loopuser1
        uid: 61001
        shell: /bin/bash
      - name: loopuser2
        uid: 61002
        shell: /sbin/nologin
  tasks:
    - name: Show user details from dict list
      ansible.builtin.debug:
        msg: "user={{ item.name }} uid={{ item.uid }} shell={{ item.shell }}"
      loop: "{{ users }}"

Access fields with dot notation: item.name, item.uid, item.shell. This article assumes you already know list and dict syntax from the variables guide.


Use loop_control

The default loop variable is item. Use it in module arguments, when, and loop_control.label. In nested loops, the inner loop overwrites item unless you rename one level with loop_control.loop_var.

loop_control adjusts how loops look and behave in logs.

Change the loop variable name

Rename item when it would clash with another loop or a play variable:

yaml
- name: Install with custom loop variable
  ansible.builtin.dnf:
    name: "{{ pkg }}"
    state: present
  loop: "{{ package_names }}"
  loop_control:
    loop_var: pkg
  become: true

Add labels to loop output

label replaces the long default (item={...}) line with something readable:

yaml
loop_control:
  loop_var: pkg
  label: "{{ pkg }}"

Task output shows (item=tree) instead of dumping the entire data structure.

Pause between loop items

pause waits that many seconds before the next iteration—useful when a remote API or fragile service needs breathing room:

yaml
loop_control:
  label: "{{ pkg }}"
  pause: 1

Keep pauses short in production playbooks; long delays add up quickly across many hosts.

Track the loop index

Set index_var when you need the zero-based position of the current item:

yaml
loop_control:
  label: "{{ pkg }}"
  index_var: idx

Use idx in messages or conditions when order matters. Ansible also exposes extended loop variables for advanced cases; see the official loop_control docs when you need more than item and idx.


Register Results from a Loop

register on a loop task stores all iterations in one variable. The shape differs from a single-task register.

yaml
- name: Run command per item
  ansible.builtin.command: "echo {{ item }}"
  loop:
    - alpha
    - beta
  register: echo_loop
  changed_when: false

Create the register demo:

bash
cat > ~/ansible-project/playbooks/loop-register-demo.yml << 'EOF'
---
- name: register loop demo
  hosts: lab
  gather_facts: false
  tasks:
    - name: Run command per item
      ansible.builtin.command: "echo {{ item }}"
      loop:
        - alpha
        - beta
      register: echo_loop
      changed_when: false

    - name: Show results list length
      ansible.builtin.debug:
        msg: "loop count={{ echo_loop.results | length }}"

    - name: Show first item stdout
      ansible.builtin.debug:
        msg: "item0 stdout={{ echo_loop.results[0].stdout }}"
EOF

Run it:

bash
ansible-playbook playbooks/loop-register-demo.yml

Sample output:

output
TASK [Show results list length] ************************************************
ok: [rocky2] => {
    "msg": "loop count=2"
}

TASK [Show first item stdout] **************************************************
ok: [rocky2] => {
    "msg": "item0 stdout=alpha"
}

For deeper register field reference, see register variables—this page only covers what loops add to the structure.

After a loop, the registered variable has a results list—one entry per iteration (each entry looks like a normal register result plus item). There is no registered.stdout at the top level. Read registered.results[n].stdout, registered.results[n].rc, or loop over registered.results in a follow-up task.

Field Meaning
results List of per-item results
msg Often All items completed when every iteration finished
changed Summary flag for the loop task as a whole

To find failed iterations:

yaml
- name: List failed loop items if any
  ansible.builtin.debug:
    msg: "failed item={{ item.item }} rc={{ item.rc }}"
  loop: "{{ echo_loop.results }}"
  when: item.failed | default(false)

When every item succeeds, Ansible skips that task.


Use when with Loops

when is evaluated per item. Combine it with loop to process only matching entries:

yaml
- name: Filter loop items with when
  ansible.builtin.debug:
    msg: "Would manage {{ item.name }}"
  loop: "{{ web_packages }}"
  when: item.enabled | default(false)

Use default(false) when some items may omit the flag. Full conditional patterns live in the when conditionals guide.


Loop Over Dictionaries

A YAML mapping is not the right shape for a normal loop when you need both keys and values. Convert it to a list of key/value objects with dict2items so each iteration has predictable item.key and item.value fields:

yaml
- name: Loop over dictionary
  hosts: lab
  vars:
    app_ports:
      http: 80
      https: 443
  tasks:
    - name: Loop over dictionary keys and values
      ansible.builtin.debug:
        msg: "{{ item.key }}={{ item.value }}"
      loop: "{{ app_ports | dict2items }}"

Each item has key and value. For advanced filters (subelements, product, and similar), use a dedicated Jinja filters reference—this article stops at the common dict2items pattern.


loop vs with_items and with_dict

Older playbooks used with_items, with_dict, and other with_* lookups. For most new tasks, use loop:

Legacy Modern replacement
with_items: "{{ mylist }}" loop: "{{ mylist }}"
with_dict: "{{ mydict }}" loop: "{{ mydict | dict2items }}"

loop is recommended for most use cases, but with_<lookup> is not deprecated and remains valid in existing playbooks.

Do not blindly convert every with_items task to loop. with_items performs implicit one-level flattening, while loop does not. If the old task used nested lists and you need the same behavior, use flatten(1) during migration. Some lookup-based patterns such as with_fileglob may still be cleaner than forcing everything into loop.


What is until in Ansible?

until retries the same task until an expression becomes true or retries is exhausted. Unlike loop, it does not walk a list—it re-runs one task until a condition passes.

Typical uses:

  • Wait for a command to succeed
  • Poll until a file or API response is ready
  • Retry a fragile step with backoff via delay

Use until with retries and delay

Keyword Role
until Jinja expression that must be true to stop retrying
retries Maximum attempts (default 3)
delay Seconds to wait between attempts (default 5)
yaml
- name: Retry command until rc is zero
  ansible.builtin.command: test -f /etc/passwd
  register: passwd_probe
  until: passwd_probe.rc == 0
  retries: 3
  delay: 2
  changed_when: false

After the task finishes, passwd_probe.attempts shows how many tries Ansible used.


Retry Until a Command Succeeds

A task with until can retry failed attempts until the condition becomes true. Do not use ignore_errors for normal retry logic, because that can hide the final failure.

Use failed_when: false only when a non-zero return code is acceptable for your probe and you plan to evaluate the result yourself. If the task should fail after all retries are exhausted, let the final failed result stand or write an explicit failed_when condition.

The official docs show until with register, retries, and delay—no failed_when: false required when a failing command should keep retrying until the expression passes:

yaml
- name: Retry until marker file exists
  ansible.builtin.command: test -f /tmp/loop-ready.flag
  register: ready_check
  until: ready_check.rc == 0
  retries: 5
  delay: 1
  changed_when: false

Create the until demo:

bash
cat > ~/ansible-project/playbooks/loop-until-demo.yml << 'EOF'
---
- name: until retry demo
  hosts: lab
  gather_facts: false
  tasks:
    - name: Retry until marker file exists
      ansible.builtin.command: test -f /tmp/loop-ready.flag
      register: ready_check
      until: ready_check.rc == 0
      retries: 5
      delay: 1
      changed_when: false

    - name: Show until metadata
      ansible.builtin.debug:
        msg: "attempts={{ ready_check.attempts }} rc={{ ready_check.rc }}"
EOF

Prepare the marker file on the managed host, then run the playbook:

bash
ansible lab -m file -a "path=/tmp/loop-ready.flag state=touch"
ansible-playbook playbooks/loop-until-demo.yml

Sample output:

output
TASK [Show until metadata] *****************************************************
ok: [rocky2] => {
    "msg": "attempts=1 rc=0"
}

If the file were missing, Ansible would retry up to five times, one second apart, before marking the task failed. The registered result includes attempts so you can see how many tries ran.


Wait and Poll Patterns with until

Use until when you control the command or script being retried:

  • Service script returns non-zero until startup finishes
  • HTTP check via uri until status is 200
  • Polling async_status until a background job completes (keep async details short; deep async patterns belong in a dedicated async guide)

until fits “run this task again until it reports success.” It is not a substitute for every wait scenario—see the comparison below.

You can combine loop and until when each item needs its own retry cycle—for example, checking several URLs until each returns status 200. Keep this simple; if the retry logic becomes hard to read, split it into smaller tasks.


until vs wait_for vs wait_for_connection

Tool Best for
until Retry a task you write (command, uri, custom check) until an expression is true
wait_for Wait for a TCP port, path, or timeout without hand-writing retry logic
wait_for_connection Wait until Ansible can connect to a host again after reboot or provisioning

Example wait_for for an SSH port:

yaml
- name: Wait for SSH port on managed host
  ansible.builtin.wait_for:
    host: "{{ ansible_host | default(inventory_hostname) }}"
    port: 22
    timeout: 10

Use wait_for when the official module already models the wait. Use until when you need custom return-code or JSON checks on your own task.


Common Loop Examples

These are expression patterns—not full playbooks:

  • Package list: loop: "{{ package_names }}" or pass the list to name when arguments match
  • User dict list: loop: "{{ users }}" with item.name, item.uid
  • Per-item filter: when: item.enabled | default(false)
  • Loop register audit: loop: "{{ pkg_query.results }}" to read per-item rc or stdout
  • Readiness poll: until: result.rc == 0 with retries and delay

Common Loop Mistakes

Symptom Likely cause Fix
Invalid data passed to loop loop received a string or non-list Use default([]) or wantlist=True on lookups
Inner loop breaks outer item Nested loops share the name item Set loop_control.loop_var on one level
registered.stdout is undefined Expecting single-task register shape Use registered.results[n].stdout
retries does not retry retries without a clear until, or final failure hidden with ignore_errors Use until: result.rc == 0; avoid ignore_errors unless you handle failure later
Slow package installs Looping when module accepts a list Pass the list to name (dnf/apt) in one task
Legacy with_* mixed with loop Old and new styles combined Standardize on loop when editing; check flatten(1) when migrating with_items

  1. Prefer one module call with a list when the module documents list support.
  2. Ensure loop input is always a list—guard optional data with default([]).
  3. Use loop for per-item differences (different args, when, or labels).
  4. Rename loop variables in nested loops before item collisions cause subtle bugs.
  5. Register loop output once, then read results—never assume top-level stdout.
  6. Use until with a clear condition; avoid ignore_errors as a retry shortcut.
  7. Keep loop_control.label on noisy dict loops so logs stay readable.

Full Demo Playbook

This playbook ties together list loops, dict lists, when, dict2items, loop_control, and register output:

bash
cat > ~/ansible-project/playbooks/loop-demo.yml << 'EOF'
---
- name: Ansible loop demo
  hosts: lab
  gather_facts: true
  vars:
    package_names:
      - tree
      - which
    users:
      - name: loopuser1
        uid: 61001
        shell: /bin/bash
      - name: loopuser2
        uid: 61002
        shell: /sbin/nologin
    web_packages:
      - name: tree
        enabled: true
      - name: which
        enabled: false
    app_ports:
      http: 80
      https: 443
  tasks:
    - name: Echo each number
      ansible.builtin.command: "echo {{ item }}"
      loop:
        - 1
        - 2
        - 3
      changed_when: false

    - name: Install with loop_control label and pause
      ansible.builtin.dnf:
        name: "{{ pkg }}"
        state: present
      loop: "{{ package_names }}"
      loop_control:
        loop_var: pkg
        label: "{{ pkg }}"
        pause: 1
      become: true

    - name: Show user details from dict list
      ansible.builtin.debug:
        msg: "user={{ item.name }} uid={{ item.uid }} shell={{ item.shell }}"
      loop: "{{ users }}"

    - name: Filter loop items with when
      ansible.builtin.debug:
        msg: "Would manage {{ item.name }}"
      loop: "{{ web_packages }}"
      when: item.enabled | default(false)

    - name: Loop over dictionary keys and values
      ansible.builtin.debug:
        msg: "{{ item.key }}={{ item.value }}"
      loop: "{{ app_ports | dict2items }}"

    - name: Run command per item and register
      ansible.builtin.command: "rpm -q {{ item }}"
      loop:
        - tree
        - which
      register: pkg_query
      changed_when: false

    - name: Show registered loop result count
      ansible.builtin.debug:
        msg: "results count={{ pkg_query.results | length }}"

    - name: Show first item stdout
      ansible.builtin.debug:
        msg: "first stdout={{ pkg_query.results[0].stdout }}"
EOF

Run it:

bash
cd ~/ansible-project
ansible-playbook playbooks/loop-demo.yml

Sample output:

output
TASK [Filter loop items with when] *********************************************
ok: [rocky2] => (item=tree) => {
    "msg": "Would manage tree"
}
skipping: [rocky2] => (item=which)

TASK [Show registered loop result count] ***************************************
ok: [rocky2] => {
    "msg": "results count=2"
}

TASK [Show first item stdout] **************************************************
ok: [rocky2] => {
    "msg": "first stdout=tree-2.1.0-8.el10.x86_64"
}

PLAY RECAP *********************************************************************
rocky2                     : ok=8    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The changed count depends on whether tree and which were already installed. First run may show changed=1 or higher; reruns are usually changed=0.


Summary

Ansible loop repeats a task for each list item; until retries a task until a condition passes or attempts run out. Use item (or loop_control.loop_var) for the current value, read loop registers via results, and filter per item with when. Prefer passing lists directly to modules when supported, use wait_for for port and path waits, and reserve until for custom retry logic. Those patterns keep playbooks short, safe to rerun, and easy to read on real inventories.


References

  • Ansible loops — official loop guide
  • Ansible conditionals (until) — until, retries, delay
  • dnf module — list name vs loop
  • wait_for module — port and path waits
  • Ansible variables — list and dict data for loops
  • Ansible when conditionals — per-item when patterns
  • Register variables — full register field reference

Frequently Asked Questions

1. What is the difference between loop and until in Ansible?

loop runs a task once per list item. until retries the same task until a condition becomes true or retries are exhausted.

2. What is the default loop variable in Ansible?

item. Rename it with loop_control.loop_var when item would conflict, such as in nested loops.

3. How do I read registered output from a loop?

Use the results list on the registered variable, for example registered.results[0].stdout. There is no top-level stdout on the registered name itself.

4. Should I use loop or with_items?

loop is recommended for most new tasks. with_* lookups are not deprecated and remain valid; when migrating from with_items, check flattening behavior because loop does not flatten nested lists the same way.

5. When should I pass a list directly to a module instead of loop?

When the module documents list support for its main option, such as dnf or apt name, one module call is usually more efficient than looping.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …