Modern Loop Syntax

# The modern way (Ansible 2.5+)
- name: Install packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - git
    - curl

# Even better — pass list directly
- name: Install packages
  ansible.builtin.apt:
    name:
      - nginx
      - git
      - curl
    state: present

Migration from with_items

# OLD — with_items (still works but deprecated style)
- name: Create users
  ansible.builtin.user:
    name: "{{ item }}"
  with_items:
    - alice
    - bob

# NEW — loop (preferred)
- name: Create users
  ansible.builtin.user:
    name: "{{ item }}"
  loop:
    - alice
    - bob

Loop with Dictionaries

# Loop over list of dicts
- name: Create users with groups
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
  loop:
    - { name: alice, groups: admin }
    - { name: bob, groups: developers }

# OLD with_dict → NEW loop + dict2items
- name: Set sysctl values
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
  loop: "{{ sysctl_settings | dict2items }}"
  vars:
    sysctl_settings:
      net.ipv4.ip_forward: 1
      net.ipv4.conf.all.forwarding: 1

Loop Control

# Custom loop variable (avoids conflicts in nested loops)
- name: Outer loop
  ansible.builtin.include_tasks: inner.yml
  loop:
    - server1
    - server2
  loop_control:
    loop_var: outer_item

# Show label instead of full item
- name: Deploy apps
  ansible.builtin.command: deploy {{ item.name }}
  loop: "{{ apps }}"
  loop_control:
    label: "{{ item.name }}"  # Shows just the name, not full dict

# Access loop index
- name: Numbered output
  ansible.builtin.debug:
    msg: "{{ index }}: {{ item }}"
  loop:
    - first
    - second
    - third
  loop_control:
    index_var: index

Conditional Loops

- name: Start only enabled services
  ansible.builtin.service:
    name: "{{ item.name }}"
    state: started
  loop: "{{ services }}"
  when: item.enabled | bool
  vars:
    services:
      - { name: nginx, enabled: true }
      - { name: postgres, enabled: false }

Loop with Register

- name: Check services
  ansible.builtin.command: systemctl status {{ item }}
  loop:
    - nginx
    - postgresql
    - redis
  register: service_status
  ignore_errors: true

- name: Show failed services
  ansible.builtin.debug:
    msg: "{{ item.item }} is down!"
  loop: "{{ service_status.results }}"
  when: item.rc != 0

Flatten & Subelements

# Flatten nested lists
- name: Install all packages
  ansible.builtin.apt:
    name: "{{ item }}"
  loop: "{{ packages | flatten }}"
  vars:
    packages:
      - [nginx, git]
      - [curl, wget]

# Subelements — loop over nested structure
- name: Add SSH keys for users
  ansible.posix.authorized_key:
    user: "{{ item.0.name }}"
    key: "{{ item.1 }}"
  loop: "{{ users | subelements('ssh_keys') }}"
  vars:
    users:
      - name: alice
        ssh_keys:
          - ssh-ed25519 AAAA... alice@laptop
          - ssh-ed25519 BBBB... alice@desktop

Performance: until + retries

- name: Wait for service to be ready
  ansible.builtin.uri:
    url: http://localhost:8080/health
  register: health
  until: health.status == 200
  retries: 30
  delay: 5

Conclusion

Use loop for all new playbooks. Migrate with_* gradually. Use loop_control for labels and nested loop variables. Pass lists directly to modules when possible — it's faster than looping.