4 min read
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_taskspattern that solves it - Why
loop_varis essential (and what breaks without it) - Performance considerations for large datasets
Series: Ansible Development Best Practices
- Part A: Achieving idempotency
- Part B: Debugging and troubleshooting
- Part C: Mastering nested loops (you are here)
- Part D: Enterprise features
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.
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:
- Run against 5 hosts first
- Verify correct behavior in job output
- 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



