The use-loop rule in ansible-lint warns when you use the older with_* looping syntax instead of the modern loop keyword. While with_* still works, loop is the recommended syntax for all new playbooks. This guide shows you how to convert every with_* pattern to loop.
Understanding the Error
When ansible-lint finds with_* syntax, it produces:
WARNING use-loop: Use `loop` instead of `with_items`. (use-loop)
playbook.yml:12 Task/Handler: Install packages
Why the Rule Exists
loopis the modern standard — introduced in Ansible 2.5 as a replacement forwith_*- Consistency — one looping syntax instead of 20+
with_*variants - Future-proofing —
with_*may eventually be deprecated - Readability —
loopcombined with filters is more explicit about data transformations
Quick Conversion Reference
| Old Syntax | New Syntax |
|---|---|
with_items: list | loop: "{{ list }}" |
with_list: list | loop: "{{ list }}" |
with_dict: dict | loop: "{{ dict | dict2items }}" |
with_fileglob: pattern | loop: "{{ query('fileglob', pattern) }}" |
with_filetree: path | loop: "{{ query('filetree', path) }}" |
with_together: [a, b] | loop: "{{ a | zip(b) | list }}" |
with_nested: [a, b] | loop: "{{ a | product(b) | list }}" |
with_subelements: [list, key] | loop: "{{ list | subelements(key) }}" |
with_sequence: ... | loop: "{{ range(start, end+1) | list }}" |
with_random_choice: list | loop: "{{ [list | random] }}" |
with_first_found: list | loop: "{{ query('first_found', list) }}" |
with_inventory_hostnames: pattern | loop: "{{ query('inventory_hostnames', pattern) }}" |
Conversion Examples
with_items → loop
The most common conversion:
# OLD — triggers use-loop warning
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
with_items:
- nginx
- curl
- git
# NEW — preferred syntax
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- nginx
- curl
- git
Even better — many modules accept lists directly:
# BEST — no loop needed
- name: Install packages
ansible.builtin.apt:
name:
- nginx
- curl
- git
state: present
with_items (flattened list) → loop + flatten
with_items automatically flattens nested lists. loop does not:
# OLD — with_items flattens automatically
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
with_items: "{{ package_lists }}"
# If package_lists = [['nginx', 'curl'], ['git']], with_items flattens it
# NEW — must explicitly flatten
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
loop: "{{ package_lists | flatten }}"
with_dict → loop + dict2items
# OLD
- name: Create users
ansible.builtin.user:
name: "{{ item.key }}"
comment: "{{ item.value.full_name }}"
groups: "{{ item.value.groups }}"
with_dict: "{{ users }}"
# NEW
- name: Create users
ansible.builtin.user:
name: "{{ item.key }}"
comment: "{{ item.value.full_name }}"
groups: "{{ item.value.groups }}"
loop: "{{ users | dict2items }}"
with_fileglob → loop + query
# OLD
- name: Copy config files
ansible.builtin.copy:
src: "{{ item }}"
dest: /etc/myapp/
with_fileglob:
- "files/configs/*.conf"
# NEW
- name: Copy config files
ansible.builtin.copy:
src: "{{ item }}"
dest: /etc/myapp/
loop: "{{ query('fileglob', 'files/configs/*.conf') }}"
with_together → loop + zip
# OLD
- name: Create mount points
ansible.builtin.mount:
path: "{{ item.0 }}"
src: "{{ item.1 }}"
fstype: ext4
state: mounted
with_together:
- ['/mnt/data1', '/mnt/data2']
- ['/dev/sdb1', '/dev/sdc1']
# NEW
- name: Create mount points
ansible.builtin.mount:
path: "{{ item.0 }}"
src: "{{ item.1 }}"
fstype: ext4
state: mounted
loop: "{{ ['/mnt/data1', '/mnt/data2'] | zip(['/dev/sdb1', '/dev/sdc1']) | list }}"
with_nested → loop + product
# OLD
- name: Grant database permissions
community.mysql.mysql_user:
name: "{{ item[0] }}"
host: "{{ item[1] }}"
priv: "*.*:ALL"
with_nested:
- ['user1', 'user2']
- ['localhost', '192.168.1.%']
# NEW
- name: Grant database permissions
community.mysql.mysql_user:
name: "{{ item[0] }}"
host: "{{ item[1] }}"
priv: "*.*:ALL"
loop: "{{ ['user1', 'user2'] | product(['localhost', '192.168.1.%']) | list }}"
with_subelements → loop + subelements
# OLD
- name: Add SSH keys for all users
ansible.posix.authorized_key:
user: "{{ item.0.name }}"
key: "{{ item.1 }}"
with_subelements:
- "{{ users }}"
- ssh_keys
# NEW
- name: Add SSH keys for all users
ansible.posix.authorized_key:
user: "{{ item.0.name }}"
key: "{{ item.1 }}"
loop: "{{ users | subelements('ssh_keys') }}"
with_sequence → loop + range
# OLD
- name: Create numbered directories
ansible.builtin.file:
path: "/data/volume_{{ item }}"
state: directory
with_sequence: start=1 end=10 format=%02d
# NEW
- name: Create numbered directories
ansible.builtin.file:
path: "/data/volume_{{ '%02d' | format(item) }}"
state: directory
loop: "{{ range(1, 11) | list }}"
Controlling loop Output
By default, Ansible prints every loop iteration. Use loop_control to manage output:
- name: Install many packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop: "{{ packages }}"
loop_control:
label: "{{ item }}" # What to show in output (reduce noise)
pause: 1 # Seconds between iterations
index_var: idx # Access loop index
loop_var: pkg # Rename 'item' (useful for nested loops)
Disabling the Rule
If you prefer with_* syntax or have a large codebase:
# .ansible-lint
skip_list:
- use-loop
# Or per-task
- name: Legacy task
ansible.builtin.debug:
msg: "{{ item }}"
with_items: "{{ my_list }}" # noqa: use-loop
Links
Related Articles
- Ansible Best Practices for Production Environments
- Ansible Jinja2 Length Filter Guide
- Ansible set_fact Module Guide
- Ansible Debug Module Guide
- Ansible Lint: Best Practices and Automation
- Ansible Tutorial for Beginners
- Ansible Fully Qualified Collection Name (FQCN)
Conclusion
Migrating from with_* to loop is straightforward once you know the filter equivalents. The key patterns are: flatten for with_items on nested lists, dict2items for with_dict, query() for lookup-based with_*, and zip/product/subelements for the combination loops. Update your playbooks incrementally — start with new code using loop, and convert existing with_* as you touch those files.