AnsibleXV · Conditionals and LoopsLoops
Reading inherited with_* code
What you'll learn
- Read the common with_* forms in an inherited repository
- Map each form to its loop equivalent using the documented conversions
- Identify the conversion that changes behaviour and prove the difference
- Decide when converting legacy loops is worth the review risk
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
with_items, with_dict, with_nested and the rest are still
supported and still work on ansible-core 2.21. They are not removed
and there is no deprecation warning. Upstream recommends loop for new
code and continues to document the with_* forms precisely so that
inherited code remains readable.
That combination — works fine, not recommended — is exactly the situation where a conversion project can do more harm than good, so this lesson covers reading before it covers converting.
What each form does
Every with_* keyword is backed by a lookup plugin. with_items
uses the items lookup, with_fileglob uses fileglob, and so on.
The keyword is with_ plus the plugin name, which is why there are so
many of them and why third-party collections could add more.
$ ansible-playbook legacy.ymlTASK [with_items] **************************************************************
ok: [localhost] => (item=curl) => {
"msg": "curl"
}
ok: [localhost] => (item=jq) => {
"msg": "jq"
}$ ansible-playbook legacy.ymlTASK [with_dict] ***************************************************************
ok: [localhost] => (item={'key': 'api', 'value': 'https://api.example.com'}) => {
"msg": "api -> https://api.example.com"
}
ok: [localhost] => (item={'key': 'metrics', 'value': 'https://metrics.example.com'}) => {
"msg": "metrics -> https://metrics.example.com"
}$ ansible-playbook legacy.ymlTASK [with_subelements] ********************************************************
ok: [localhost] => (item=[{'name': 'alice'}, 'adm']) => {
"msg": "alice in adm"
}
ok: [localhost] => (item=[{'name': 'alice'}, 'sudo']) => {
"msg": "alice in sudo"
}
ok: [localhost] => (item=[{'name': 'bob'}, 'docker']) => {
"msg": "bob in docker"
}with_subelements is the one that reads worst. item.0 is the parent,
item.1 is the child, and the code around it will be full of
item.0.name and item.1 with nothing naming what either is. That
opacity is the strongest argument for converting this particular form.
The conversion table
Upstream documents the mapping. Every row below was confirmed by
running both forms on ansible-core 2.21.3 and comparing the
iterations.
| Legacy | Modern equivalent |
|---|---|
with_list | loop |
with_items | loop + flatten(levels=1) |
with_flattened | loop + flatten |
with_indexed_items | loop + flatten(levels=1) + index_var |
with_together | loop + zip |
with_dict | loop + dict2items (or dictsort) |
with_sequence | loop + range |
with_subelements | loop + subelements |
with_nested / with_cartesian | loop + product |
Two of them verified side by side:
$ ansible-playbook conv.ymlTASK [with_nested equivalence] *************************************************
ok: [localhost] => (item=['alice', 'app']) => { "msg": "alice/app" }
ok: [localhost] => (item=['alice', 'logs']) => { "msg": "alice/logs" }
ok: [localhost] => (item=['bob', 'app']) => { "msg": "bob/app" }
ok: [localhost] => (item=['bob', 'logs']) => { "msg": "bob/logs" }
TASK [loop + product equivalence] **********************************************
ok: [localhost] => (item=['alice', 'app']) => { "msg": "alice/app" }
ok: [localhost] => (item=['alice', 'logs']) => { "msg": "alice/logs" }
ok: [localhost] => (item=['bob', 'app']) => { "msg": "bob/app" }
ok: [localhost] => (item=['bob', 'logs']) => { "msg": "bob/logs" }$ ansible-playbook conv.ymlTASK [with_together equivalence] ***********************************************
ok: [localhost] => (item=['alice', 'app']) => { "msg": "alice=app" }
ok: [localhost] => (item=['bob', 'logs']) => { "msg": "bob=logs" }
TASK [loop + zip equivalence] **************************************************
ok: [localhost] => (item=['alice', 'app']) => { "msg": "alice=app" }
ok: [localhost] => (item=['bob', 'logs']) => { "msg": "bob=logs" }Note the item.0 / item.1 addressing survives both conversions —
product and zip produce pairs exactly as with_nested and
with_together did, so the task body does not change.
The conversion that changes behaviour
with_items flattens one level. loop does not. This is the single
most important line in the lesson.
$ ansible-playbook legacy.ymlTASK [with_items flattens one level] *******************************************
ok: [localhost] => (item=a) => { "msg": "a" }
ok: [localhost] => (item=b) => { "msg": "b" }
ok: [localhost] => (item=c) => { "msg": "c" }
TASK [loop does NOT flatten] ***************************************************
ok: [localhost] => (item=['a', 'b']) => {
"msg": [
"a",
"b"
]
}
ok: [localhost] => (item=['c']) => {
"msg": [
"c"
]
}Three iterations became two, and item changed from a string to a
list. That is why the conversion table says
loop + flatten(levels=1) and not just loop:
$ ansible-playbook conv.ymlTASK [with_items flatten equivalence] ******************************************
ok: [localhost] => {
"msg": "with_items -> ['a', 'b', 'c']"
}with_fileglob and the controller-side forms
Some with_* forms have no plain-data equivalent because they are
doing I/O:
# Legacy
- name: Deploy every config fragment
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/app/conf.d/{{ item | basename }}"
mode: '0644'
with_fileglob:
- files/conf.d/*.conf
# Modern - the same lookup, called explicitly
- name: Deploy every config fragment
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/app/conf.d/{{ item | basename }}"
mode: '0644'
loop: "{{ query('ansible.builtin.fileglob', 'files/conf.d/*.conf') }}"
loop_control:
label: "{{ item | basename }}"The pattern generalises: with_<plugin> becomes
loop: "{{ query('<plugin>', ...) }}". Use query rather than
lookup here, because query always returns a list, whereas lookup
returns a comma-joined string by default and iterating that gives you
one very strange item.
fileglob reads the controller, not the target. It always did, and
that surprises people who assume a loop over files is looking at the
managed node. find is the module that inspects the target.
Should you convert?
Not automatically, and not as a project of its own.
Convert when you are already editing the task. The risk is bounded by a change you are testing anyway.
Convert with_subelements and with_nested on sight. These are
the ones whose item.0 / item.1 addressing makes surrounding code
unreadable, and the conversion lets you add a loop_var and a label
that name what the values are.
Convert with_dict where a label is needed. A with_dict over
credentials prints them, exactly as lesson 4 described, and adding
loop_control is a good enough reason on its own.
Leave a working with_items over a literal list alone. It works,
it is readable, converting it has a nonzero chance of introducing the
flattening bug, and the benefit is stylistic.
Knowledge check
Knowledge check · 4 questions
Q1. A task uses with_items over a variable that sometimes holds a list of lists. It is converted to loop with no filter. What changes?
Q2. What is the correct modern equivalent of with_nested over two lists?
Q3. Which are good reasons to convert a legacy with_* loop? Select all that apply.
Q4. with_fileglob reads the controller filesystem rather than the managed node.
Passing score: 75%. Answers are checked in this browser.