Complete Guide to Ansible Loops: Iteration, Nested Loops, and Advanced Loop Control with Examples

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.

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 and product
  • 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?

RS

About the Author: Ramesh Sundararamaiah

Red Hat Certified Architect

Ramesh is a Red Hat Certified Architect with extensive experience in enterprise Linux environments. He specializes in system administration, DevOps automation, and cloud infrastructure. Ramesh has helped organizations implement robust Linux solutions and optimize their IT operations for performance and reliability.

Expertise: Red Hat Enterprise Linux, CentOS, Ubuntu, Docker, Ansible, System Administration, DevOps

Add Comment