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

Featureloop (modern)with_items (legacy)until (retry)
IntroducedAnsible 2.5Ansible 1.xAnsible 1.4
Status✅ Recommended⚠️ Still works, not deprecated✅ Active
PurposeIterate over a listIterate over a listRetry until condition met
Flattening❌ No auto-flatten✅ Auto-flattens nested listsN/A
Filters✅ Full Jinja2 filter supportLimitedN/A
loop_control✅ Full support✅ WorksN/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]

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.