Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsLoops

Reading inherited with_* code

Intermediate⏱ ~22 minansible-playbook

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

Not yet marked complete on this device.

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.

Read-only / Safewith_items over a flat list
$ ansible-playbook legacy.yml
TASK [with_items] **************************************************************
ok: [localhost] => (item=curl) => {
  "msg": "curl"
}
ok: [localhost] => (item=jq) => {
  "msg": "jq"
}
Read-only / Safewith_dict over a dictionary
$ ansible-playbook legacy.yml
TASK [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"
}
Read-only / Safewith_subelements over a list of records with nested lists
$ ansible-playbook legacy.yml
TASK [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.

LegacyModern equivalent
with_listloop
with_itemsloop + flatten(levels=1)
with_flattenedloop + flatten
with_indexed_itemsloop + flatten(levels=1) + index_var
with_togetherloop + zip
with_dictloop + dict2items (or dictsort)
with_sequenceloop + range
with_subelementsloop + subelements
with_nested / with_cartesianloop + product

Two of them verified side by side:

Read-only / Safewith_nested and loop + product produce identical iterations
$ ansible-playbook conv.yml
TASK [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" }
Read-only / Safewith_together and loop + zip produce identical iterations
$ ansible-playbook conv.yml
TASK [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.

Read-only / Safethe same input, two different iteration counts
$ ansible-playbook legacy.yml
TASK [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:

Read-only / Safeflatten(levels=1) restores the with_items behaviour
$ ansible-playbook conv.yml
TASK [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:

Read-only / Safelegacy: with_fileglob, and the modern form
# 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

  1. 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?

  2. Q2. What is the correct modern equivalent of with_nested over two lists?

  3. Q3. Which are good reasons to convert a legacy with_* loop? Select all that apply.

  4. Q4. with_fileglob reads the controller filesystem rather than the managed node.

Passing score: 75%. Answers are checked in this browser.