Introduction
Ansible has two loop syntaxes: the modern loop keyword and the legacy with_* plugins. It also has until for retry loops. Knowing which to use — and how to migrate — keeps your playbooks modern and readable.
Quick Comparison
| Feature | loop (modern) | with_items (legacy) | until (retry) |
|---|---|---|---|
| Introduced | Ansible 2.5 | Ansible 1.x | Ansible 1.4 |
| Status | ✅ Recommended | ⚠️ Still works, not deprecated | ✅ Active |
| Purpose | Iterate over a list | Iterate over a list | Retry until condition met |
| Flattening | ❌ No auto-flatten | ✅ Auto-flattens nested lists | N/A |
| Filters | ✅ Full Jinja2 filter support | Limited | N/A |
loop_control | ✅ Full support | ✅ Works | N/A |
loop — Modern Syntax
# Simple list iteration
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- nginx
- python3
- redis-server
# Loop over a variable
- name: Create users
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
state: present
loop:
- { name: alice, groups: "admin,docker" }
- { name: bob, groups: "docker" }
- { name: charlie, groups: "developers" }
# Loop with index
- name: Create numbered configs
ansible.builtin.template:
src: worker.conf.j2
dest: "/etc/app/worker-{{ idx }}.conf"
loop: "{{ range(1, worker_count + 1) | list }}"
loop_control:
loop_var: idx
# Loop with filters
- name: Install only defined packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop: "{{ packages | select('defined') | list }}"
loop_control
# Rename loop variable (required for nested includes)
- ansible.builtin.include_tasks: setup-user.yml
loop: "{{ users }}"
loop_control:
loop_var: current_user
# Show custom label in output
- name: Deploy to servers
ansible.builtin.include_tasks: deploy.yml
loop: "{{ servers }}"
loop_control:
label: "{{ item.name }}"
# Shows "Deploy to servers => web1" instead of full dict
# Add index
- name: Process items
ansible.builtin.debug:
msg: "Item {{ idx }}: {{ item }}"
loop: "{{ my_list }}"
loop_control:
index_var: idx
# Add pause between iterations
- name: Rolling restart
ansible.builtin.service:
name: myapp
state: restarted
loop: "{{ groups['webservers'] }}"
loop_control:
pause: 10 # Wait 10 seconds between each host
with_items — Legacy Syntax
# ⚠️ Legacy but still works
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
with_items:
- nginx
- python3
# Auto-flattens nested lists (loop does NOT)
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
with_items:
- [nginx, python3]
- [redis-server]
# Processes: nginx, python3, redis-server (flattened)
Migration from with_* to loop
# with_items → loop
# Before:
- debug: msg="{{ item }}"
with_items: "{{ my_list }}"
# After:
- debug: msg="{{ item }}"
loop: "{{ my_list }}"
# with_items (nested) → loop + flatten
# Before:
- debug: msg="{{ item }}"
with_items: "{{ list_of_lists }}"
# After:
- debug: msg="{{ item }}"
loop: "{{ list_of_lists | flatten }}"
# with_dict → loop + dict2items
# Before:
- debug: msg="{{ item.key }}={{ item.value }}"
with_dict: "{{ my_dict }}"
# After:
- debug: msg="{{ item.key }}={{ item.value }}"
loop: "{{ my_dict | dict2items }}"
# with_fileglob → loop + fileglob lookup
# Before:
- copy: src="{{ item }}" dest=/etc/configs/
with_fileglob: "configs/*.conf"
# After:
- copy: src="{{ item }}" dest=/etc/configs/
loop: "{{ lookup('fileglob', 'configs/*.conf', wantlist=True) }}"
# with_sequence → loop + range
# Before:
- debug: msg="worker-{{ item }}"
with_sequence: start=1 end=5
# After:
- debug: msg="worker-{{ item }}"
loop: "{{ range(1, 6) | list }}"
# with_nested → loop + product
# Before:
- debug: msg="{{ item.0 }}-{{ item.1 }}"
with_nested:
- [a, b]
- [1, 2]
# After:
- debug: msg="{{ item.0 }}-{{ item.1 }}"
loop: "{{ ['a','b'] | product([1,2]) | list }}"
until — Retry Loop
# Retry until condition is met
- name: Wait for application to start
ansible.builtin.uri:
url: "http://localhost:{{ app_port }}/health"
register: health
until: health.status == 200
retries: 30 # Max attempts (default: 3)
delay: 10 # Seconds between retries (default: 5)
# Wait for file to appear
- name: Wait for deployment marker
ansible.builtin.stat:
path: /opt/app/DEPLOYED
register: marker
until: marker.stat.exists
retries: 60
delay: 5
# Wait for command to succeed
- name: Wait for database
ansible.builtin.command: pg_isready -h {{ db_host }}
register: pg_ready
until: pg_ready.rc == 0
retries: 20
delay: 3
changed_when: false
# Combine loop and until
- name: Wait for all services
ansible.builtin.uri:
url: "http://{{ item }}:8080/health"
register: svc_health
until: svc_health.status == 200
retries: 10
delay: 5
loop:
- service-a
- service-b
- service-c
Common Patterns
Batch Processing
# Process items in batches of 3
- name: Deploy in batches
ansible.builtin.include_tasks: deploy-batch.yml
loop: "{{ servers | batch(3) | list }}"
loop_control:
loop_var: batch
Conditional Loop
# Skip items that don't match
- name: Install only needed packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop: "{{ all_packages }}"
when: item in required_packages
Common Mistakes
# ❌ Using with_items for new playbooks
- apt: name="{{ item }}"
with_items: [nginx, redis]
# ✅ Use loop (modern, consistent)
- apt: name="{{ item }}"
loop: [nginx, redis]
# ❌ Forgetting flatten when migrating from with_items
- debug: msg="{{ item }}"
loop: "{{ nested_list }}"
# May iterate over sub-lists instead of items!
# ✅ Add flatten
- debug: msg="{{ item }}"
loop: "{{ nested_list | flatten }}"
# ❌ Using loop when the module accepts a list
- ansible.builtin.apt:
name: "{{ item }}"
loop: [nginx, redis, python3]
# ✅ Pass the list directly (faster — one apt call)
- ansible.builtin.apt:
name: [nginx, redis, python3]
Related Articles
- Ansible Loop Guide
- Ansible map vs selectattr vs json_query
- Ansible Jinja2 Templates
- Ansible Strategies Guide
Conclusion
loop is the modern standard — use it for all new playbooks. with_items still works but offers no advantage over loop. until is for retry patterns (health checks, waiting for services). When migrating: with_items → loop (add | flatten if needed), with_dict → loop + dict2items, with_sequence → loop + range. Pro tip: if a module accepts a list parameter, pass the whole list instead of looping — it's faster.