Mastering nested loops in Ansible

Mastering nested loops in Ansible

You need to loop over users AND groups. You try two loop: keywords. Ansible says no.

Ansible doesn't support nested loops directly. You can't put two loop: statements on a single task. But you can achieve the same result with include_tasks — once you understand the pattern.

What you'll learn:

  • Why Ansible doesn't allow direct nested loops
  • The include_tasks pattern that solves it
  • Why loop_var is essential (and what breaks without it)
  • Performance considerations for large datasets

Series: Ansible Development Best Practices


The problem

This doesn't work:

# THIS WILL FAIL
- name: Process users and groups
  ansible.builtin.debug:
    msg: "{{ user }} in {{ group }}"
  loop: "{{ users }}"
  loop: "{{ groups }}"  # Can't have two loop keywords!

Ansible is not a general-purpose programming language. It processes tasks sequentially and only allows one loop per task.


The solution: include_tasks

Split your nested logic into two files:

main.yml (outer loop):

---
- name: Nested loop demonstration
  hosts: localhost
  gather_facts: false
  vars:
    users:
      - alice
      - bob
    groups:
      - developers
      - admins

  tasks:
    - name: Process each user (OUTER LOOP)
      ansible.builtin.include_tasks: process_user.yml
      loop: "{{ users }}"
      loop_control:
        loop_var: current_user

process_user.yml (inner loop):

---
- name: Add user to each group (INNER LOOP)
  ansible.builtin.debug:
    msg: "Adding {{ current_user }} to {{ item }}"
  loop: "{{ groups }}"

Output:

Adding alice to developers
Adding alice to admins
Adding bob to developers
Adding bob to admins

The outer loop calls include_tasks for each user. The included file runs its own loop over groups.


Why loop_var matters

By default, Ansible names the loop variable item. Without loop_var, both loops try to use item and the inner loop overwrites the outer:

# WITHOUT loop_var - BROKEN
- name: Outer loop
  include_tasks: inner.yml
  loop: "{{ users }}"
  # Uses "item" for current user

# inner.yml also uses "item" - overwrites the user!
- name: Inner loop
  debug:
    msg: "{{ item }}"  # This is now the group, not the user
  loop: "{{ groups }}"

Fix: Rename the outer loop variable:

loop_control:
  loop_var: current_user  # Now inner loop can safely use "item"

Real-world example: User and SSH key management

main.yml:

---
- name: Configure user SSH keys
  hosts: servers
  vars:
    user_keys:
      - username: alice
        keys:
          - "ssh-rsa AAAA... alice@laptop"
          - "ssh-rsa BBBB... alice@desktop"
      - username: bob
        keys:
          - "ssh-rsa CCCC... bob@workstation"

  tasks:
    - name: Process each user
      ansible.builtin.include_tasks: add_user_keys.yml
      loop: "{{ user_keys }}"
      loop_control:
        loop_var: user_data

add_user_keys.yml:

---
- name: Ensure user exists
  ansible.builtin.user:
    name: "{{ user_data.username }}"
    state: present

- name: Add SSH keys for user
  ansible.posix.authorized_key:
    user: "{{ user_data.username }}"
    key: "{{ item }}"
    state: present
  loop: "{{ user_data.keys }}"

This creates each user, then adds all their SSH keys. Clean separation of concerns.


Filtering in nested loops

Process only specific items with when:

- name: Process only active users
  ansible.builtin.include_tasks: process_user.yml
  loop: "{{ users }}"
  loop_control:
    loop_var: current_user
  when: current_user.active | default(true)

Or match against a list:

vars:
  target_users:
    - alice
    - charlie

tasks:
  - name: Process specific users only
    ansible.builtin.include_tasks: process_user.yml
    loop: "{{ all_users }}"
    loop_control:
      loop_var: current_user
    when: current_user in target_users

Test complex loops safely

Nested loops can run thousands of iterations. Ascender Pro lets you test on a subset first with --limit, monitor execution in real time, and schedule large runs for maintenance windows.

Build with Ascender Pro →


Performance considerations

Nested loops multiply quickly:

Outer items Inner items Total iterations
10 10 100
100 100 10,000
1,000 50 50,000

Each iteration has task overhead. For large datasets:

Batch when possible

# SLOW: One SSH connection per user/server combination
- name: Check user on each server
  ansible.builtin.shell: id {{ current_user }}
  delegate_to: "{{ item }}"
  loop: "{{ all_servers }}"

# FASTER: One playbook run per server, all users at once
- name: Check all users
  hosts: all_servers
  tasks:
    - name: Check users in batch
      ansible.builtin.shell: |
        for user in {{ users | join(' ') }}; do
          id $user 2>/dev/null || echo "$user: not found"
        done

Test with --limit first

In Ascender Pro, use the --limit option to test on a subset:

  1. Run against 5 hosts first
  2. Verify correct behavior in job output
  3. Schedule full run for off-peak hours

Consider alternatives

Sometimes there's a non-loop solution:

# Instead of looping to install packages one by one...
- name: Install packages (loop)
  ansible.builtin.dnf:
    name: "{{ item }}"
    state: present
  loop: "{{ packages }}"

# ...install them all at once
- name: Install packages (batch)
  ansible.builtin.dnf:
    name: "{{ packages }}"
    state: present

The batch version makes one dnf transaction instead of N.

Triple-nested loops

Yes, you can go deeper:

main.yml
  └─> level1.yml (include_tasks with loop_var: item_l1)
      └─> level2.yml (include_tasks with loop_var: item_l2)
          └─> level3.yml (uses item for innermost loop)

Each level needs its own loop_var to avoid collisions. But if you need triple nesting, consider whether there's a simpler data structure.

Quick reference: Loop patterns

Pattern Use case Example
Simple loop Iterate over list loop: "{{ users }}"
include_tasks Nested iteration Outer file calls inner file
loop_var Avoid variable collision loop_control: loop_var: current_user
when + loop Filter items when: item.active
dict2items Loop over dictionary `loop: "{{ my_dict dict2items }}"`
subelements User + nested list `loop: "{{ users subelements('keys') }}"`

Debugging loop problems

When nested loops misbehave, see Part B: Debugging and troubleshooting for techniques. Key tips:

# Print what the outer loop is passing
- name: Debug outer variable
  ansible.builtin.debug:
    var: current_user

# Print inner loop iteration
- name: Debug inner variable
  ansible.builtin.debug:
    msg: "Processing {{ current_user }} with {{ item }}"
  loop: "{{ groups }}"

In Ascender Pro, click any task in the job output to see the JSON with all variable values — no debug tasks needed.


Next steps

Ready for enterprise-scale automation?

Continue with Part D: Enterprise features (coming soon) to see how Ascender Pro handles compliance, security, and multi-team workflows.

Or build with Ascender Pro to test your nested loops with enterprise features like job scheduling, limit testing, and 90-day execution history.


Ready to learn more about what CIQ can do for you?

Get in touch

Related posts

Achieving idempotency with shell commands

Achieving idempotency with shell commands

Ansible audit trails don't belong in your SIEM

Ansible audit trails don't belong in your SIEM

Ansible Import vs. Include: What’s the Real Difference?

Ansible Import vs. Include: What’s the Real Difference?

Compliance Automation with Ascender Pro

Compliance Automation with Ascender Pro

Built for scale. Chosen by the world’s best.

2.75M+

Rocky Linux instances

Being used world wide

90%

Of fortune 100 companies

Use CIQ supported technologies

250k

Avg. monthly downloads

Rocky Linux