4 min read

Mastering nested loops in Ansible

March 9, 2026
Mastering nested loops in Ansible

Table of contents

The problemThe solution: include_tasksWhy loop_var mattersReal-world example: User and SSH key managementFiltering in nested loopsPerformance considerationsBatch when possibleTest with --limit firstConsider alternativesTriple-nested loopsQuick reference: Loop patternsDebugging loop problemsNext steps

Subscribe to our newsletter

Subscribe

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.


Built for Scale. Chosen by the World’s Best.

1.4M+

Rocky Linux instances

Being used world wide

90%

Of fortune 100 companies

Use CIQ supported technologies

250k

Avg. monthly downloads

Rocky Linux

Related posts

Achieving idempotency with shell commands

Achieving idempotency with shell commands

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

CVE management: automate discovery to remediation

CVE management: automate discovery to remediation