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.
~/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.
- 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: falseCreate a small demo playbook:
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
EOFRun it:
cd ~/ansible-project
ansible-playbook playbooks/loop-basic-demo.ymlSample 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:
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.
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:
- 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: trueThe 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:
- name: Install multiple packages in one module call
ansible.builtin.dnf:
name: "{{ package_names }}"
state: present
become: trueOne 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:
- 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:
- name: Install with custom loop variable
ansible.builtin.dnf:
name: "{{ pkg }}"
state: present
loop: "{{ package_names }}"
loop_control:
loop_var: pkg
become: trueAdd labels to loop output
label replaces the long default (item={...}) line with something readable:
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:
loop_control:
label: "{{ pkg }}"
pause: 1Keep 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:
loop_control:
label: "{{ pkg }}"
index_var: idxUse 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.
- name: Run command per item
ansible.builtin.command: "echo {{ item }}"
loop:
- alpha
- beta
register: echo_loop
changed_when: falseCreate the register demo:
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 }}"
EOFRun it:
ansible-playbook playbooks/loop-register-demo.ymlSample 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:
- 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:
- 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:
- 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) |
- 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: falseAfter 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:
- 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: falseCreate the until demo:
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 }}"
EOFPrepare the marker file on the managed host, then run the playbook:
ansible lab -m file -a "path=/tmp/loop-ready.flag state=touch"
ansible-playbook playbooks/loop-until-demo.ymlSample 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
uriuntil status is 200 - Polling
async_statusuntil 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:
- name: Wait for SSH port on managed host
ansible.builtin.wait_for:
host: "{{ ansible_host | default(inventory_hostname) }}"
port: 22
timeout: 10Use 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 tonamewhen arguments match - User dict list:
loop: "{{ users }}"withitem.name,item.uid - Per-item filter:
when: item.enabled | default(false) - Loop register audit:
loop: "{{ pkg_query.results }}"to read per-itemrcorstdout - Readiness poll:
until: result.rc == 0withretriesanddelay
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 |
Recommended Loop Style
- Prefer one module call with a list when the module documents list support.
- Ensure
loopinput is always a list—guard optional data withdefault([]). - Use
loopfor per-item differences (different args,when, or labels). - Rename loop variables in nested loops before
itemcollisions cause subtle bugs. - Register loop output once, then read
results—never assume top-levelstdout. - Use
untilwith a clear condition; avoidignore_errorsas a retry shortcut. - Keep
loop_control.labelon 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:
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 }}"
EOFRun it:
cd ~/ansible-project
ansible-playbook playbooks/loop-demo.ymlSample 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=0The 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
namevsloop - wait_for module — port and path waits
- Ansible variables — list and dict data for loops
- Ansible when conditionals — per-item
whenpatterns - Register variables — full register field reference

