Loops are essential for automating repetitive tasks in Ansible, allowing you to execute the same task across multiple items efficiently. This comprehensive guide covers all loop types in Ansibleβfrom simple iteration to complex nested loops, with practical real-world examples that will make your automation code cleaner and more maintainable.
π Table of Contents
- What are Loops in Ansible?
- Why Use Loops?
- Basic Loop Syntax
- The Modern Way: loop Keyword
- Without Loop (Inefficient)
- Simple Loops
- Loop Over a List
- Loop Over Inline List
- Loop with Item Index
- Loop Variables and Metadata
- Looping Over Dictionaries
- Using dict2items Filter
- Accessing Dictionary Values
- Looping Over List of Dictionaries
- Nested Loops
- Using subelements for Nested Data
- Cartesian Product with product Filter
- Complex Nested Structures
- Loops with Conditionals
- Filter Items with when
- Multiple Conditions
- Range Loops
- Simple Numeric Range
- Range with Step
- Range with Formatting
- Loop Control
- Custom Loop Variable Name
- Add Labels for Cleaner Output
- Pause Between Iterations
- Extended Loop Information
- Until Loops (Retry Logic)
- Loops with Registered Variables
- File Globbing Loops
- Loops with Flatten
- Loops with Zip
- Loops with Selectattr and Rejectattr
- Sequence Loops (Legacy)
- Async Loops
- Include Tasks with Loops
- Best Practices
- 1. Use loop Instead of with_*
- 2. Use Labels for Complex Items
- 3. Handle Empty Lists
- 4. Use Appropriate Loop Type
- Common Patterns
- Create Multiple Resources
- Batch Operations
- Conditional Execution per Item
- Summary
What are Loops in Ansible?
Loops in Ansible allow you to repeat a task multiple times with different values. Instead of writing the same task repeatedly with different parameters, you use loops to iterate over a list of items.
Why Use Loops?
- DRY Principle: Don’t Repeat Yourself – write once, execute many times
- Maintainability: Update one task instead of dozens
- Scalability: Easily add or remove items
- Readability: Cleaner, more organized playbooks
- Efficiency: Less code, same result
Basic Loop Syntax
The Modern Way: loop Keyword
- name: Install multiple packages
apt:
name: "{{ item }}"
state: present
loop:
- vim
- git
- htop
- curl
Without Loop (Inefficient)
# DON'T DO THIS - repetitive and hard to maintain
- name: Install vim
apt:
name: vim
state: present
- name: Install git
apt:
name: git
state: present
- name: Install htop
apt:
name: htop
state: present
- name: Install curl
apt:
name: curl
state: present
Simple Loops
Loop Over a List
vars:
packages:
- nginx
- postgresql
- redis
- memcached
tasks:
- name: Install required packages
apt:
name: "{{ item }}"
state: present
update_cache: yes
loop: "{{ packages }}"
Loop Over Inline List
- name: Create multiple directories
file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- /opt/app/bin
- /opt/app/config
- /opt/app/logs
- /opt/app/data
- /opt/app/temp
Loop with Item Index
- name: Create numbered backup directories
file:
path: "/backups/backup_{{ item }}"
state: directory
loop: "{{ range(1, 6) | list }}"
# Creates: backup_1, backup_2, backup_3, backup_4, backup_5
Loop Variables and Metadata
Ansible provides special variables inside loops to track iteration progress:
- name: Display loop information
debug:
msg: |
Item: {{ item }}
Index (1-based): {{ loop_index }}
Index (0-based): {{ loop_index0 }}
First iteration: {{ loop_first }}
Last iteration: {{ loop_last }}
Total items: {{ loop_length }}
Items remaining: {{ loop_revindex }}
Items remaining (0-based): {{ loop_revindex0 }}
loop:
- apple
- banana
- cherry
Output example for “banana”:
Item: banana
Index (1-based): 2
Index (0-based): 1
First iteration: False
Last iteration: False
Total items: 3
Items remaining: 2
Items remaining (0-based): 1
Real-World Example: Creating numbered configuration files:
- name: Create sequentially numbered vhost configs
template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/site{{ '%02d' | format(loop_index) }}.conf"
loop: "{{ range(1, 6) | list }}"
# Creates: site01.conf, site02.conf, site03.conf, site04.conf, site05.conf
Looping Over Dictionaries
Using dict2items Filter
vars:
services:
nginx:
port: 80
ssl_port: 443
enabled: true
postgresql:
port: 5432
enabled: true
redis:
port: 6379
enabled: false
tasks:
- name: Display service information
debug:
msg: "Service: {{ item.key }}, Port: {{ item.value.port }}, Enabled: {{ item.value.enabled }}"
loop: "{{ services | dict2items }}"
Output:
Service: nginx, Port: 80, Enabled: True
Service: postgresql, Port: 5432, Enabled: True
Service: redis, Port: 6379, Enabled: False
Accessing Dictionary Values
vars:
users:
alice:
uid: 1001
shell: /bin/bash
groups: ['wheel', 'developers']
bob:
uid: 1002
shell: /bin/zsh
groups: ['developers']
tasks:
- name: Create users from dictionary
user:
name: "{{ item.key }}"
uid: "{{ item.value.uid }}"
shell: "{{ item.value.shell }}"
groups: "{{ item.value.groups | join(',') }}"
state: present
loop: "{{ users | dict2items }}"
Looping Over List of Dictionaries
This is the most common pattern in real-world automation:
vars:
databases:
- name: appdb
host: db1.example.com
port: 5432
backup: true
- name: cachedb
host: db2.example.com
port: 5432
backup: false
- name: analyticsdb
host: db3.example.com
port: 5432
backup: true
tasks:
- name: Configure database connections
template:
src: db_connection.j2
dest: "/etc/app/db_{{ item.name }}.conf"
loop: "{{ databases }}"
- name: Setup database backups
cron:
name: "Backup {{ item.name }}"
hour: "2"
minute: "0"
job: "/usr/local/bin/backup_db.sh {{ item.name }} {{ item.host }}"
loop: "{{ databases }}"
when: item.backup
Nested Loops
Using subelements for Nested Data
vars:
users:
- name: alice
groups: ['wheel', 'developers', 'docker']
- name: bob
groups: ['developers', 'docker']
- name: charlie
groups: ['users']
tasks:
- name: Add users to all their groups
debug:
msg: "Adding {{ item.0.name }} to group {{ item.1 }}"
loop: "{{ users | subelements('groups') }}"
Output:
Adding alice to group wheel
Adding alice to group developers
Adding alice to group docker
Adding bob to group developers
Adding bob to group docker
Adding charlie to group users
Cartesian Product with product Filter
- name: Deploy app to all environments on all servers
debug:
msg: "Deploying {{ item.0 }} to {{ item.1 }}"
loop: "{{ ['app1', 'app2'] | product(['prod', 'staging', 'dev']) | list }}"
Output:
Deploying app1 to prod
Deploying app1 to staging
Deploying app1 to dev
Deploying app2 to prod
Deploying app2 to staging
Deploying app2 to dev
Real-World Example: Configure monitoring for all services on all hosts:
vars:
monitoring_hosts: ['monitor1.example.com', 'monitor2.example.com']
services_to_monitor: ['nginx', 'postgresql', 'redis']
tasks:
- name: Setup monitoring checks
nagios_service:
service: "{{ item.1 }}_{{ item.0 }}"
host: "{{ item.0 }}"
check_command: "check_{{ item.1 }}"
loop: "{{ monitoring_hosts | product(services_to_monitor) | list }}"
Complex Nested Structures
vars:
web_servers:
- hostname: web01
ip: 192.168.1.10
apps:
- name: frontend
port: 3000
- name: api
port: 8080
- hostname: web02
ip: 192.168.1.11
apps:
- name: frontend
port: 3000
- name: backend
port: 9000
tasks:
- name: Configure firewall for all apps
debug:
msg: "Allow traffic to {{ item.0.hostname }} ({{ item.0.ip }}) for {{ item.1.name }} on port {{ item.1.port }}"
loop: "{{ web_servers | subelements('apps') }}"
Loops with Conditionals
Filter Items with when
vars:
services:
- name: nginx
enabled: true
port: 80
- name: apache
enabled: false
port: 80
- name: postgresql
enabled: true
port: 5432
tasks:
- name: Start only enabled services
service:
name: "{{ item.name }}"
state: started
loop: "{{ services }}"
when: item.enabled
# Only nginx and postgresql will be started
Multiple Conditions
vars:
users:
- name: alice
role: admin
active: true
- name: bob
role: developer
active: true
- name: charlie
role: admin
active: false
tasks:
- name: Grant sudo to active admin users
lineinfile:
path: /etc/sudoers.d/{{ item.name }}
line: "{{ item.name }} ALL=(ALL) NOPASSWD: ALL"
create: yes
loop: "{{ users }}"
when:
- item.role == 'admin'
- item.active == true
# Only alice gets sudo access
Real-World Example: Install packages only on specific OS:
- name: Install packages based on OS
package:
name: "{{ item.name }}"
state: present
loop:
- name: apache2
os_family: Debian
- name: httpd
os_family: RedHat
- name: vim
os_family: all
when: item.os_family == ansible_os_family or item.os_family == 'all'
Range Loops
Simple Numeric Range
- name: Create 10 user accounts
user:
name: "testuser{{ item }}"
state: present
loop: "{{ range(1, 11) | list }}"
# Creates: testuser1, testuser2, ..., testuser10
Range with Step
- name: Configure services on specific ports
debug:
msg: "Configure service on port {{ item }}"
loop: "{{ range(8000, 8010, 2) | list }}"
# Ports: 8000, 8002, 8004, 8006, 8008
Range with Formatting
- name: Create numbered directories with leading zeros
file:
path: "/data/volume_{{ '%03d' | format(item) }}"
state: directory
loop: "{{ range(1, 101) | list }}"
# Creates: volume_001, volume_002, ..., volume_100
Real-World Example: Create database shards:
vars:
shard_count: 16
tasks:
- name: Create database shards
postgresql_db:
name: "appdb_shard_{{ '%02d' | format(item) }}"
state: present
loop: "{{ range(1, shard_count + 1) | list }}"
Loop Control
Custom Loop Variable Name
- name: Use custom loop variable for clarity
debug:
msg: "Processing server {{ server_item.hostname }}"
loop: "{{ web_servers }}"
loop_control:
loop_var: server_item
# Useful when nesting loops to avoid variable conflicts
Add Labels for Cleaner Output
- name: Process users with labels
user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
groups: "{{ item.groups }}"
loop:
- name: alice
uid: 1001
groups: wheel,developers
- name: bob
uid: 1002
groups: developers
loop_control:
label: "{{ item.name }}"
# Output shows only "alice" and "bob" instead of entire dictionary
Pause Between Iterations
- name: Restart services with delay
service:
name: "{{ item }}"
state: restarted
loop:
- nginx
- postgresql
- redis
loop_control:
pause: 5 # Wait 5 seconds between each service restart
Extended Loop Information
- name: Show extended loop info
debug:
msg: "Processing {{ item }} - {{ loop_index }}/{{ loop_length }}"
loop:
- task1
- task2
- task3
loop_control:
extended: yes
Until Loops (Retry Logic)
Use until
to retry a task until a condition is met:
- name: Wait for service to become active
shell: systemctl is-active nginx
register: service_status
until: service_status.rc == 0
retries: 10
delay: 5
# Checks every 5 seconds, up to 10 times (50 seconds total)
Real-World Examples:
# Wait for API to be ready
- name: Wait for API to respond
uri:
url: "http://localhost:8080/health"
status_code: 200
register: api_health
until: api_health.status == 200
retries: 30
delay: 10
# Wait for database connection
- name: Check database connectivity
postgresql_ping:
db: myapp
register: db_check
until: db_check.is_available
retries: 20
delay: 3
# Wait for file to appear
- name: Wait for application startup marker
stat:
path: /var/run/app.pid
register: pid_file
until: pid_file.stat.exists
retries: 60
delay: 2
Loops with Registered Variables
When you register a variable inside a loop, it captures all results:
- name: Check disk usage on all mount points
shell: df -h {{ item }}
loop:
- /
- /home
- /var
- /tmp
register: disk_usage_results
- name: Display all disk usage results
debug:
msg: "{{ item.stdout }}"
loop: "{{ disk_usage_results.results }}"
- name: Show specific mount point
debug:
msg: "{{ item.item }} usage: {{ item.stdout_lines[1] }}"
loop: "{{ disk_usage_results.results }}"
Accessing registered variable structure:
disk_usage_results:
results:
- item: "/"
stdout: "..."
rc: 0
- item: "/home"
stdout: "..."
rc: 0
File Globbing Loops
- name: Process all log files
debug:
msg: "Processing {{ item }}"
with_fileglob:
- /var/log/*.log
- /var/log/app/*.log
- name: Archive all configuration files
archive:
path: "{{ item }}"
dest: "/backups/{{ item | basename }}.tar.gz"
with_fileglob:
- /etc/nginx/*.conf
- /etc/app/*.ini
Using find module with loop:
- name: Find old log files
find:
paths: /var/log
patterns: "*.log"
age: 30d
size: 100m
register: old_logs
- name: Delete old log files
file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_logs.files }}"
when: old_logs.matched > 0
Loops with Flatten
vars:
dev_packages: ['vim', 'git', 'curl']
ops_packages: ['htop', 'iotop', 'netstat']
security_packages: ['fail2ban', 'ufw']
tasks:
- name: Install all packages from multiple lists
apt:
name: "{{ item }}"
state: present
loop: "{{ dev_packages + ops_packages + security_packages }}"
# Or use flatten
# loop: "{{ [dev_packages, ops_packages, security_packages] | flatten }}"
Loops with Zip
Combine two lists element by element:
vars:
servers: ['web01', 'web02', 'web03']
ip_addresses: ['10.0.1.10', '10.0.1.11', '10.0.1.12']
tasks:
- name: Add hosts to /etc/hosts
lineinfile:
path: /etc/hosts
line: "{{ item.0 }} {{ item.1 }}"
loop: "{{ ip_addresses | zip(servers) | list }}"
# Creates:
# 10.0.1.10 web01
# 10.0.1.11 web02
# 10.0.1.12 web03
Loops with Selectattr and Rejectattr
vars:
all_servers:
- name: web01
type: web
active: true
- name: web02
type: web
active: false
- name: db01
type: database
active: true
- name: cache01
type: cache
active: true
tasks:
- name: Configure only active web servers
debug:
msg: "Configuring {{ item.name }}"
loop: "{{ all_servers | selectattr('type', 'equalto', 'web') | selectattr('active') | list }}"
# Only processes: web01
- name: Skip database servers
debug:
msg: "Processing {{ item.name }}"
loop: "{{ all_servers | rejectattr('type', 'equalto', 'database') | list }}"
# Processes: web01, web02, cache01
Sequence Loops (Legacy)
- name: Create numbered users
user:
name: "user{{ item }}"
with_sequence: start=1 end=5
# Creates: user1, user2, user3, user4, user5
- name: Create formatted sequence
debug:
msg: "Server: web{{ item }}"
with_sequence: start=1 end=10 format=%02d
# Outputs: web01, web02, ..., web10
- name: Sequence with step
debug:
msg: "Port {{ item }}"
with_sequence: start=8000 end=8010 stride=2
# Outputs: 8000, 8002, 8004, 8006, 8008, 8010
Async Loops
Run loop iterations in parallel with async:
- name: Run long-running tasks in parallel
command: "/usr/local/bin/long_task.sh {{ item }}"
async: 300 # Maximum runtime
poll: 0 # Don't wait, continue immediately
loop:
- task1
- task2
- task3
register: async_results
- name: Wait for all async tasks to complete
async_status:
jid: "{{ item.ansible_job_id }}"
register: async_poll_results
until: async_poll_results.finished
retries: 30
delay: 10
loop: "{{ async_results.results }}"
when: item.ansible_job_id is defined
Real-World Example: Backup multiple databases in parallel:
- name: Start database backups in parallel
shell: "mysqldump {{ item }} > /backups/{{ item }}_{{ ansible_date_time.date }}.sql"
async: 1800
poll: 0
loop:
- database1
- database2
- database3
- database4
register: backup_jobs
- name: Wait for all backups to complete
async_status:
jid: "{{ item.ansible_job_id }}"
register: backup_status
until: backup_status.finished
retries: 120
delay: 15
loop: "{{ backup_jobs.results }}"
Include Tasks with Loops
# main.yml
- name: Configure multiple applications
include_tasks: configure_app.yml
loop:
- app1
- app2
- app3
loop_control:
loop_var: app_name
# configure_app.yml
- name: Create app directory
file:
path: "/opt/{{ app_name }}"
state: directory
- name: Deploy app config
template:
src: "{{ app_name }}_config.j2"
dest: "/opt/{{ app_name }}/config.ini"
Best Practices
1. Use loop Instead of with_*
# Modern (recommended)
loop: "{{ items }}"
# Legacy (still works but deprecated)
with_items: "{{ items }}"
2. Use Labels for Complex Items
# Good - clear output
- name: Create users
user:
name: "{{ item.name }}"
# ... many parameters
loop: "{{ users }}"
loop_control:
label: "{{ item.name }}"
# Bad - cluttered output showing entire dictionary
- name: Create users
user:
name: "{{ item.name }}"
loop: "{{ users }}"
3. Handle Empty Lists
- name: Process items safely
debug:
msg: "Processing {{ item }}"
loop: "{{ items | default([]) }}"
# Won't fail if items is undefined
4. Use Appropriate Loop Type
# For simple lists
loop: "{{ packages }}"
# For dictionaries
loop: "{{ services | dict2items }}"
# For nested data
loop: "{{ users | subelements('groups') }}"
# For ranges
loop: "{{ range(1, 11) | list }}"
# For file patterns
with_fileglob: "/path/*.conf"
Common Patterns
Create Multiple Resources
- name: Create multiple virtual hosts
template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ item.name }}.conf"
loop:
- name: site1
domain: site1.example.com
port: 80
- name: site2
domain: site2.example.com
port: 8080
notify: reload nginx
Batch Operations
- name: Process items in batches
debug:
msg: "Batch: {{ item }}"
loop: "{{ range(1, 101) | list | batch(10) | list }}"
# Processes 100 items in 10 batches of 10
Conditional Execution per Item
- name: Install package only if not present
apt:
name: "{{ item }}"
state: present
loop: "{{ packages }}"
when: item not in ansible_facts.packages
Summary
Ansible loops provide powerful iteration capabilities:
- Simple Loops: Iterate over lists with
loop
- Dictionary Loops: Use
dict2items
filter - Nested Loops:
subelements
andproduct
- Conditional Loops: Combine with
when
clause - Range Loops: Generate numeric sequences
- Loop Control: Custom variables, labels, pauses
- Until Loops: Retry until condition met
- Registered Variables: Capture all results
- Async Loops: Parallel execution
Master loops to eliminate repetitive tasks and create maintainable, scalable Ansible automation.
Was this article helpful?