Ansible Loops

Last updated: June 30, 2025

My Journey with Ansible Loops

When I first started working with Ansible, I quickly found myself repeating tasks in my playbooks. I was writing the same task multiple times with slight variations—creating users, installing packages, configuring services with different parameters. This approach not only made my playbooks unnecessarily lengthy but also difficult to maintain.

That's when I discovered the power of loops in Ansible. Learning to use loops transformed the way I write playbooks. Suddenly, I could define a single task that would execute multiple times with different inputs. My playbooks became shorter, more maintainable, and much more powerful.

In this blog post, I'll share my experience with Ansible loops, demonstrating how they can simplify automation across both Linux and Windows environments. Whether you're managing a handful of servers or a complex multi-platform infrastructure, mastering loops will take your Ansible automation to the next level.

Understanding Loops in Ansible

Loops in Ansible allow you to perform the same task multiple times with different values. This concept is similar to programming language loops, especially Python's for loop. If you're familiar with other programming languages, you'll find Ansible loops quite intuitive.

Historically, Ansible provided various ways to implement loops:

  1. The legacy with_* keywords (e.g., with_items, with_file)

  2. The modern loop keyword (introduced in Ansible 2.5)

While with_* keywords are still supported for backward compatibility, the Ansible documentation recommends using the loop keyword for most use cases as it provides a more consistent and predictable interface.

Basic Loop Usage

Let's start with a simple example. Imagine you need to create multiple users on your Linux servers:

---
- name: Create multiple users
  hosts: linux_servers
  tasks:
    - name: Add several users
      ansible.builtin.user:
        name: "{{ item }}"
        state: present
        groups: "wheel"
      loop:
        - devuser
        - testuser
        - adminuser

In this example, the user module will run three times, once for each value in the loop. The {{ item }} variable holds the current value in each iteration.

For Windows servers, a similar approach can be used:

---
- name: Create multiple users
  hosts: windows_servers
  tasks:
    - name: Add Windows users
      win_user:
        name: "{{ item }}"
        password: "SecureP@ssw0rd"
        state: present
        groups: ["Users"]
      loop:
        - win_devuser
        - win_testuser
        - win_adminuser

Working with Lists of Dictionaries

Often, you'll need to provide multiple parameters for each iteration. For this, you can use a list of dictionaries:

Linux Example:

---
- name: Configure multiple users with specific settings
  hosts: linux_servers
  tasks:
    - name: Add users with specific groups
      ansible.builtin.user:
        name: "{{ item.name }}"
        state: present
        groups: "{{ item.groups }}"
        shell: "{{ item.shell | default('/bin/bash') }}"
      loop:
        - { name: 'devuser', groups: 'developers' }
        - { name: 'testuser', groups: 'testers', shell: '/bin/zsh' }
        - { name: 'adminuser', groups: 'sudo,admin' }

Windows Example:

---
- name: Configure multiple IIS websites
  hosts: windows_servers
  tasks:
    - name: Create IIS websites
      win_iis_website:
        name: "{{ item.name }}"
        state: started
        port: "{{ item.port }}"
        physical_path: "{{ item.path }}"
      loop:
        - { name: 'MainApp', port: 80, path: 'C:\\inetpub\\mainapp' }
        - { name: 'API', port: 8080, path: 'C:\\inetpub\\api' }
        - { name: 'Admin', port: 8090, path: 'C:\\inetpub\\admin' }

Iterating Over Dictionaries

Working with dictionaries in loops requires a different approach. Ansible provides the dict2items filter for this purpose:

---
- name: Configure services with environment-specific settings
  hosts: all
  vars:
    service_config:
      web_server:
        port: 80
        max_connections: 200
      app_server:
        port: 8080
        max_connections: 100
      database:
        port: 5432
        max_connections: 50
  tasks:
    - name: Display service configurations
      ansible.builtin.debug:
        msg: "Service: {{ item.key }}, Port: {{ item.value.port }}, Max Connections: {{ item.value.max_connections }}"
      loop: "{{ service_config | dict2items }}"

This task iterates through the dictionary, allowing you to access both the keys and values.

Nested Loops

For more complex scenarios, you might need nested loops. While Ansible doesn't have a direct nested loop construct, you can achieve the same result using Jinja2's product filter:

---
- name: Configure database users across multiple databases
  hosts: database_servers
  tasks:
    - name: Set up database access
      ansible.builtin.mysql_user:
        name: "{{ item[0] }}"
        priv: "{{ item[1] }}.*:ALL"
        host: "%"
        state: present
      loop: "{{ ['app_user', 'read_user', 'admin_user'] | product(['customers', 'products', 'orders']) | list }}"

This will create all combinations of users and databases, resulting in nine iterations of the task.

Loop Control

Ansible provides the loop_control directive to manage loop behavior:

Limiting Output

When dealing with large loops, the output can become unwieldy. Use the label directive to make the output more readable:

---
- name: Install multiple packages
  hosts: linux_servers
  tasks:
    - name: Install required packages
      ansible.builtin.apt:
        name: "{{ item.package }}"
        state: present
      loop:
        - { package: 'nginx', version: 'latest' }
        - { package: 'postgresql', version: '13' }
        - { package: 'redis', version: '6.2' }
      loop_control:
        label: "{{ item.package }}"

Instead of showing the entire item dictionary in the output, Ansible will only display the package name.

Tracking Loop Progress

You can track the current position in a loop using the index_var directive:

---
- name: Deploy application components in sequence
  hosts: app_servers
  tasks:
    - name: Deploy components
      ansible.builtin.debug:
        msg: "Deploying component {{ item }} ({{ current_index + 1 }}/3)"
      loop:
        - database
        - application
        - frontend
      loop_control:
        index_var: current_index

Extended Loop Information

For advanced loop control, use the extended option:

---
- name: Process items with advanced loop control
  hosts: all
  tasks:
    - name: Process with extended information
      ansible.builtin.debug:
        msg: >
          Processing {{ item }} - 
          First: {{ ansible_loop.first }},
          Last: {{ ansible_loop.last }}, 
          Index: {{ ansible_loop.index }}/{{ ansible_loop.length }}
      loop:
        - step1
        - step2
        - step3
        - step4
      loop_control:
        extended: true

Looping with Conditionals

Combining loops with conditionals gives you even more flexibility:

Linux Example:

---
- name: Install packages based on distribution
  hosts: linux_servers
  tasks:
    - name: Install packages
      ansible.builtin.package:
        name: "{{ item.package }}"
        state: present
      loop:
        - { package: 'httpd', distro: 'CentOS' }
        - { package: 'apache2', distro: 'Ubuntu' }
        - { package: 'firewalld', distro: 'CentOS' }
        - { package: 'ufw', distro: 'Ubuntu' }
      when: ansible_facts['distribution'] == item.distro

Windows Example:

---
- name: Configure Windows features based on server role
  hosts: windows_servers
  tasks:
    - name: Install Windows features
      win_feature:
        name: "{{ item.feature }}"
        state: present
      loop:
        - { feature: 'Web-Server', role: 'web' }
        - { feature: 'Web-Mgmt-Tools', role: 'web' }
        - { feature: 'RSAT-AD-Tools', role: 'domain-controller' }
        - { feature: 'RSAT-DNS-Server', role: 'domain-controller' }
      when: server_role == item.role

Registering Variables with Loop

You can capture the results of a loop using the register keyword:

---
- name: Check service status
  hosts: all
  tasks:
    - name: Check if services are running
      ansible.builtin.command: systemctl status {{ item }}
      loop:
        - nginx
        - postgresql
        - redis
      register: service_status
      changed_when: false
      failed_when: false

    - name: Display failed services
      ansible.builtin.debug:
        msg: "Service {{ item.item }} is not running"
      loop: "{{ service_status.results }}"
      when: item.rc != 0

When registering the result of a loop, Ansible creates a list in the .results attribute of the registered variable. Each element in this list corresponds to an iteration of the loop.

Loop Until a Condition is Met

Sometimes you need to repeat a task until a specific condition is met. The until keyword combined with retries and delay allows for this:

---
- name: Wait for service to be available
  hosts: app_servers
  tasks:
    - name: Check if application is responding
      ansible.builtin.uri:
        url: "http://localhost:8080/health"
        status_code: 200
      register: result
      until: result.status == 200
      retries: 10
      delay: 5

This task will retry up to 10 times with a 5-second delay between attempts until it gets a 200 status code from the health endpoint.

Sequence Diagram: How Loops Work in Ansible

Here's a sequence diagram that illustrates how loops are processed in Ansible:

This diagram shows how Ansible processes each item in a loop, replacing the item variable with the current value before executing the task on the target host.

Performance Considerations

While loops are powerful, they can impact performance since each iteration requires a separate connection to the target host. Here are some tips for optimizing loop performance:

  1. Use module parameters when possible: Many modules accept lists directly:

    # Less efficient with loop
    - name: Install packages with loop
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - postgresql
        - redis
    
    # More efficient direct approach
    - name: Install packages directly
      ansible.builtin.apt:
        name:
          - nginx
          - postgresql
          - redis
        state: present
  2. Use loop_control.pause for rate limiting: When hitting external APIs or services that might have rate limits:

    - name: Make API calls with rate limiting
      ansible.builtin.uri:
        url: "https://api.example.com/resource/{{ item }}"
        method: GET
      loop:
        - resource1
        - resource2
        - resource3
      loop_control:
        pause: 1  # 1 second pause between iterations

Real-World Examples

Let's look at some real-world examples that combine multiple concepts we've discussed.

Linux Server Configuration

---
- name: Configure multiple services on Linux servers
  hosts: linux_servers
  vars:
    services:
      - name: nginx
        port: 80
        config_template: "nginx.conf.j2"
        state: started
      - name: postgresql
        port: 5432
        config_template: "postgresql.conf.j2"
        state: started
      - name: redis
        port: 6379
        config_template: "redis.conf.j2"
        state: started
  tasks:
    - name: Install services
      ansible.builtin.package:
        name: "{{ item.name }}"
        state: present
      loop: "{{ services }}"
      
    - name: Configure service ports
      ansible.builtin.template:
        src: "{{ item.config_template }}"
        dest: "/etc/{{ item.name }}/conf.d/custom.conf"
      loop: "{{ services }}"
      notify: "restart {{ item.name }}"
      
    - name: Ensure services are running
      ansible.builtin.service:
        name: "{{ item.name }}"
        state: "{{ item.state }}"
        enabled: true
      loop: "{{ services }}"
      
  handlers:
    - name: restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
        
    - name: restart postgresql
      ansible.builtin.service:
        name: postgresql
        state: restarted
        
    - name: restart redis
      ansible.builtin.service:
        name: redis
        state: restarted

Windows Environment Setup

---
- name: Configure Windows development environment
  hosts: windows_dev_servers
  vars:
    dev_tools:
      - name: "Visual Studio Code"
        product_id: "{EA457B21-F73E-494C-ACAB-524FDE069978}"
        download_url: "https://code.visualstudio.com/sha/download?build=stable&os=win32-x64"
      - name: "Git for Windows"
        product_id: "{1D1DB8ED-5D57-4CBC-B829-9B0EE5C508AD}"
        download_url: "https://github.com/git-for-windows/git/releases/download/v2.37.1.windows.1/Git-2.37.1-64-bit.exe"
      - name: "Node.js"
        product_id: "{F4492219-6386-49A3-ADCE-04F8BEC74453}"
        download_url: "https://nodejs.org/dist/v16.15.1/node-v16.15.1-x64.msi"
  tasks:
    - name: Download installation files
      win_get_url:
        url: "{{ item.download_url }}"
        dest: "C:\\temp\\{{ item.name | replace(' ', '') }}-installer.exe"
      loop: "{{ dev_tools }}"
      
    - name: Install development tools
      win_package:
        path: "C:\\temp\\{{ item.name | replace(' ', '') }}-installer.exe"
        product_id: "{{ item.product_id }}"
        arguments: "/quiet /norestart"
        state: present
      loop: "{{ dev_tools }}"
      
    - name: Create project directories
      win_file:
        path: "C:\\Projects\\{{ item }}"
        state: directory
      loop:
        - Frontend
        - Backend
        - Database
        - Documentation

Common Pitfalls and Solutions

Mistake: Using loop with strings

# Incorrect
- name: Process string
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "hello"  # This won't iterate over characters as expected

Solution: Convert the string to a list:

# Correct
- name: Process string characters
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ 'hello' | list }}"  # Converts string to list of characters

Mistake: Nested structure flattening issues

# May not behave as expected
- name: Process nested list
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop:
    - [1, 2]
    - [3, 4]

Solution: Use the flatten filter with appropriate level:

# Correct
- name: Process nested list
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ [[1, 2], [3, 4]] | flatten(1) }}"

Mistake: Not accessing loop results correctly

# Incorrect access
- name: Check files
  ansible.builtin.stat:
    path: "/tmp/{{ item }}"
  loop:
    - file1
    - file2
  register: file_stats

- name: Show results
  ansible.builtin.debug:
    msg: "{{ file_stats.stat.exists }}"  # Incorrect, no 'stat' attribute in file_stats

Solution: Access through the results list:

# Correct access
- name: Show results
  ansible.builtin.debug:
    msg: "{{ item.stat.exists }}"
  loop: "{{ file_stats.results }}"

Conclusion

Loops are a fundamental feature in Ansible that can significantly improve the efficiency and maintainability of your playbooks. They allow you to perform repetitive tasks with different inputs while keeping your code DRY (Don't Repeat Yourself). Whether you're managing Linux servers, Windows environments, or a hybrid infrastructure, mastering loops will help you create more elegant and powerful automation.

As I've discovered in my automation journey, the time invested in learning how to properly use loops in Ansible pays off immensely as your infrastructure grows and your playbooks become more complex. Start with simple loops and gradually incorporate the more advanced features as you gain confidence.

Last updated