Skip to content
CloudOps
Newsletter
All guides
AI for Ansible By James Joyner IV · · 11 min read

Modernizing Ansible Loops: Migrating with_items to loop With AI

Use AI to translate legacy Ansible with_items, with_dict, and with_subelements into the modern loop keyword with loop_control, query, and filters.

  • #iac
  • #ansible
  • #automation
  • #ai-tooling

I inherited a provisioning role last spring that had clearly been written across six years and four engineers. The tell was the loops. One task used with_items, the one right below it used with_dict, and buried in an included file was a with_subelements that nobody on the team could explain without opening the Ansible 2.4 changelog. When I ran ansible-playbook --syntax-check, everything passed. When I ran it against a real host, two tasks silently looped over the wrong thing because a list-of-lists had quietly flattened itself. That afternoon convinced me to modernize every with_* loop in our codebase to the single loop: keyword — and to let AI do the tedious first pass while I reviewed each diff.

This is the playbook I wish I’d had. It covers what the old with_* forms actually did, how AI translates them cleanly, and the gotchas that will bite you if you trust the translation blindly.

Why the old with_* forms are a problem

The with_* family isn’t deprecated in the “throws a warning” sense, but the Ansible core team has been steering people toward loop: since 2.5. The trouble with with_* is that each variant hides a different lookup plugin behind a friendly-looking keyword. with_items is lookup('items', ...), with_dict is lookup('dict', ...), with_nested is the cartesian product, and so on. You can’t tell from the keyword what the data shape needs to be, and the implicit behaviors differ.

The single most dangerous one is with_items, because it auto-flattens one level of nesting. That convenience is exactly what bit my inherited role. The modern loop: keyword does not flatten, which is more predictable but means a naive find-and-replace will break things. That gap is precisely where AI helps and precisely where it can quietly hurt you.

How AI translates the common cases

I treat AI like a fast junior engineer: it knows the mechanical mapping cold, it’s tireless across hundreds of tasks, and it must never merge its own work. The straightforward translations it nails every time.

A plain with_items:

# Before
- name: "Install base packages"
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  with_items:
    - git
    - curl
    - htop

# After
- name: "Install base packages"
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop:
    - git
    - curl
    - htop

A with_dict becomes loop plus the dict2items filter, which is the canonical modern idiom:

# Before
- name: "Create users"
  ansible.builtin.user:
    name: "{{ item.key }}"
    uid: "{{ item.value.uid }}"
  with_dict: "{{ user_map }}"

# After
- name: "Create users"
  ansible.builtin.user:
    name: "{{ item.key }}"
    uid: "{{ item.value.uid }}"
  loop: "{{ user_map | dict2items }}"

When you describe the mapping table once — “with_dict maps to loop with dict2items, with_together maps to zip, with_nested maps to product” — a capable model applies it consistently. I keep that mapping in a saved prompt so I’m not re-explaining it each session. If you do a lot of this, the reusable prompts in our prompts library and the curated prompt packs save real time, and you can iterate on the wording live in the prompt workspace.

Pro Tip: Give the AI three or four of your real before/after examples up front. A model that has seen your house style for loop_control and your naming conventions produces diffs you can skim instead of re-read line by line.

The flatten gotcha you cannot skip

Here’s the one that nearly shipped a bug. with_items flattens a single level. If your variable is a list of lists, the old code “just worked”:

# Before — with_items silently flattens role_packages + extra_packages
- name: "Install grouped packages"
  ansible.builtin.apt:
    name: "{{ item }}"
  with_items:
    - "{{ role_packages }}"
    - "{{ extra_packages }}"

A literal swap to loop: will hand each task a whole list as item, and apt will choke or behave strangely. The correct migration adds the flatten filter to preserve the old behavior intentionally:

# After — flatten is explicit now
- name: "Install grouped packages"
  ansible.builtin.apt:
    name: "{{ item }}"
  loop: "{{ [role_packages, extra_packages] | flatten(levels=1) }}"

A good AI flags this when it sees nested variables, but it does not always recognize that two separate {{ var }} lines are each lists. This is review-or-regret territory. Read every with_items migration where the items aren’t plain literals, and confirm the flattening intent.

with_subelements and with_nested

These two are where engineers reach for the changelog. with_subelements walks a list and a named sublist on each element — think users and their SSH keys:

# Before
- name: "Add authorized keys"
  ansible.posix.authorized_key:
    user: "{{ item.0.name }}"
    key: "{{ item.1 }}"
  with_subelements:
    - "{{ users }}"
    - authorized_keys

# After
- name: "Add authorized keys"
  ansible.posix.authorized_key:
    user: "{{ item.0.name }}"
    key: "{{ item.1 }}"
  loop: "{{ users | subelements('authorized_keys') }}"

The subelements filter preserves the item.0 / item.1 tuple shape, so the task body usually doesn’t change. with_nested maps just as cleanly to the product filter:

# Before
- name: "Open firewall matrix"
  ansible.builtin.iptables:
    destination_port: "{{ item.0 }}"
    source: "{{ item.1 }}"
  with_nested:
    - "{{ ports }}"
    - "{{ trusted_cidrs }}"

# After
- name: "Open firewall matrix"
  ansible.builtin.iptables:
    destination_port: "{{ item.0 }}"
    source: "{{ item.1 }}"
  loop: "{{ ports | product(trusted_cidrs) | list }}"

loop_control: labels, index_var, and the var collision

The real upgrade isn’t just loop: — it’s loop_control, which the old syntax never gave you cleanly. Three features earn their keep.

label tames noisy output. When you loop over a list of dicts, default Ansible prints the entire dict on every iteration. A label trims it to something readable:

- name: "Deploy vhosts"
  ansible.builtin.template:
    src: "vhost.j2"
    dest: "/etc/nginx/sites-available/{{ item.server_name }}"
  loop: "{{ vhosts }}"
  loop_control:
    label: "{{ item.server_name }}"

index_var gives you the iteration counter without a Jinja loop.index workaround:

- name: "Number the brokers"
  ansible.builtin.set_fact:
    broker_id: "{{ idx }}"
  loop: "{{ kafka_brokers }}"
  loop_control:
    index_var: idx

The most important one is loop_var, and it’s the gotcha that silently corrupts data in included tasks. If an outer task loops with item, then include_tasks an inner file that also loops with item, the inner loop shadows the outer one. Old with_items code hit this constantly. The fix is to rename the loop variable:

# Outer task
- name: "Process each region"
  ansible.builtin.include_tasks: "configure_region.yml"
  loop: "{{ regions }}"
  loop_control:
    loop_var: region

Inside configure_region.yml, you reference region instead of item, so any inner loop: using item never collides. When AI migrates included tasks, explicitly tell it to set a distinct loop_var on every loop that wraps an include_tasks. It won’t always infer the shadowing risk on its own, and this is the kind of bug that passes --syntax-check and fails only at runtime.

Pro Tip: After every migration batch, run ansible-playbook --check --diff against a non-production host. Check mode is your safety net — it catches the flatten and shadowing mistakes that static analysis sails right past.

A safe workflow for bulk migration

Mechanical translation across a whole repo is exactly what AI is good at, and exactly where discipline matters. The workflow I use:

  1. Pin one task type per pass. Migrate all with_dict first, review, commit. Then with_subelements. Mixing types in one diff makes review harder.
  2. Keep the human in the loop on every change. AI proposes, you approve. No auto-merge, ever.
  3. Run --check --diff on every batch before it touches a real environment.
  4. Never paste your vault contents or vault password into an AI tool. Migrate the loop syntax, not the secrets — the {{ vaulted_var }} reference is all the model needs to see.

For the actual editing you can drive this from your IDE with Cursor or GitHub Copilot, or chat through the harder translations in Claude AI. Whatever you use, route the resulting diff through a real review — our code review dashboard is built for exactly this “fast junior wrote it, a senior signs off” pattern. More IaC walkthroughs live under the IaC category.

Conclusion

Modernizing with_* to loop: makes your Ansible more readable, more predictable, and ready for the patterns the core team actually supports. AI turns a week of tedious find-and-replace into an afternoon of reviewing diffs — provided you treat it like the talented junior it is. Map the syntax, watch the flatten and shadowing traps, run --check, keep your vault keys to yourself, and approve every change by hand. Do that, and your loops will finally read like one engineer wrote them.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.