Ansible

Use Ansible Conditionals and Loops for Control Flow

The first playbook that does one thing to all hosts is satisfying. The second you need different behavior per OS, per environment, or per host, you need control flow. Ansible gives you when for conditionals, loop for iteration, block/rescue/always for error handling, and until for retries. Combined, they turn static task lists into adaptive automation.

Original content from computingforgeeks.com - post 166043

This guide tests every control flow pattern on real Rocky Linux 10.1 and Ubuntu 24.04 hosts, including the cross-platform gotchas that only show up when you actually run the playbook against both OS families. We use Ansible 13.5.0 (ansible-core 2.20.4). If you need to review Ansible variables first, that guide covers facts, custom vars, and precedence rules.

Current as of April 2026. Verified on Rocky Linux 10.1 and Ubuntu 24.04 with ansible-core 2.20.4

Prerequisites

You need:

The when Conditional

The when keyword on a task evaluates a Jinja2 expression. If it’s true, the task runs. If false, Ansible skips it and moves on. No YAML trickery, no nested blocks required for simple branching.

Branching by OS Family

The most common conditional in cross-platform automation: running different tasks for RHEL and Debian family systems. Facts like ansible_facts['os_family'] make this straightforward:

---
- name: OS-Specific Tasks
  hosts: all
  become: true

  tasks:
    - name: Install EPEL on RHEL family only
      ansible.builtin.dnf:
        name: epel-release
        state: present
      when: ansible_facts['os_family'] == "RedHat"

    - name: Update apt cache on Debian family only
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
      when: ansible_facts['os_family'] == "Debian"

    - name: Show which branch ran
      ansible.builtin.debug:
        msg: "{{ ansible_facts['distribution'] }} ({{ ansible_facts['os_family'] }}) handled"

Each host runs only the task that matches its OS, and skips the other:

TASK [Install EPEL on RHEL family only] ***********
skipping: [ubuntu-managed]
changed: [rocky-managed]

TASK [Update apt cache on Debian family only] *****
skipping: [rocky-managed]
changed: [ubuntu-managed]

TASK [Show which branch ran] **********************
ok: [rocky-managed] => {
    "msg": "Rocky (RedHat) handled"
}
ok: [ubuntu-managed] => {
    "msg": "Ubuntu (Debian) handled"
}

The skipping: lines confirm that Rocky skipped the apt task and Ubuntu skipped the dnf task. This is the foundation of every cross-platform playbook.

Multiple Conditions

AND conditions use a YAML list under when. All conditions must be true:

    - name: Multiple conditions with and
      ansible.builtin.debug:
        msg: "This is a Rocky server with 2+ CPUs"
      when:
        - ansible_facts['distribution'] == "Rocky"
        - ansible_facts['processor_vcpus'] >= 2

Only the Rocky host with 2 vCPUs matches both conditions:

ok: [rocky-managed] => {
    "msg": "This is a Rocky server with 2+ CPUs"
}
skipping: [ubuntu-managed]

OR conditions use the or keyword in a single expression:

    - name: Multiple conditions with or
      ansible.builtin.debug:
        msg: "This host runs either Rocky or Ubuntu"
      when: ansible_facts['distribution'] == "Rocky" or ansible_facts['distribution'] == "Ubuntu"

Both hosts match at least one condition, so both run the task.

The in Operator and Registered Results

Testing membership in a list is cleaner than chaining OR conditions. The in operator works with any list:

    - name: Condition with in operator
      ansible.builtin.debug:
        msg: "{{ ansible_facts['distribution'] }} is a supported distro"
      when: ansible_facts['distribution'] in ['Rocky', 'Ubuntu', 'Debian', 'AlmaLinux']

Conditionals paired with registered variables let you make decisions based on command output. Check if a binary exists before trying to use it:

    - name: Check if firewalld is installed
      ansible.builtin.command: which firewall-cmd
      register: firewalld_check
      changed_when: false
      failed_when: false

    - name: Firewall management available
      ansible.builtin.debug:
        msg: "Firewall management available on {{ inventory_hostname }}"
      when: firewalld_check.rc == 0

    - name: No firewalld detected
      ansible.builtin.debug:
        msg: "No firewalld on {{ inventory_hostname }}, likely uses ufw"
      when: firewalld_check.rc != 0

The failed_when: false prevents the play from stopping when which returns a non-zero exit code on hosts without firewalld. The registered .rc attribute gives you the return code for branching logic.

Loops: Iterating Over Data

Loops prevent you from writing the same task five times with different values. The loop keyword (which replaced the older with_items in most cases) iterates over a list and makes each element available as {{ item }}.

Simple List Loop

Install multiple packages in a single task:

    - name: Install multiple packages with loop
      ansible.builtin.dnf:
        name: "{{ item }}"
        state: present
      loop:
        - tree
        - wget
        - jq
        - tmux

Ansible runs the task once per item, showing each result:

TASK [Install multiple packages with loop] ********
changed: [rocky-managed] => (item=tree)
ok: [rocky-managed] => (item=wget)
ok: [rocky-managed] => (item=jq)
changed: [rocky-managed] => (item=tmux)

Items that were already installed show ok, new installs show changed. This idempotent behavior is core to Ansible.

Loop Over Dictionaries

When each item needs multiple attributes, use a list of dictionaries:

    - name: Create multiple users with loop
      ansible.builtin.user:
        name: "{{ item.name }}"
        shell: "{{ item.shell }}"
        groups: "{{ item.groups }}"
        create_home: true
      loop:
        - { name: deploy, shell: /bin/bash, groups: wheel }
        - { name: monitor, shell: /sbin/nologin, groups: "" }
        - { name: backup, shell: /bin/bash, groups: "" }

All three users are created in one task:

changed: [rocky-managed] => (item={'name': 'deploy', 'shell': '/bin/bash', 'groups': 'wheel'})
changed: [rocky-managed] => (item={'name': 'monitor', 'shell': '/sbin/nologin', 'groups': ''})
changed: [rocky-managed] => (item={'name': 'backup', 'shell': '/bin/bash', 'groups': ''})

Verifying the users exist with id confirms the group assignments:

ok: [rocky-managed] => (item=deploy) => {
    "msg": "uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),10(wheel)"
}
ok: [rocky-managed] => (item=monitor) => {
    "msg": "uid=1002(monitor) gid=1002(monitor) groups=1002(monitor)"
}
ok: [rocky-managed] => (item=backup) => {
    "msg": "uid=1003(backup) gid=1003(backup) groups=1003(backup)"
}

Loop with Index (loop_control)

When you need a counter, loop_control with index_var provides a zero-based index:

    - name: Loop with index
      ansible.builtin.debug:
        msg: "Step {{ idx + 1 }}: {{ item }}"
      loop:
        - Validate configuration
        - Backup current state
        - Apply changes
        - Verify deployment
      loop_control:
        index_var: idx

The numbered steps print in order:

ok: [rocky-managed] => (item=Validate configuration) => {
    "msg": "Step 1: Validate configuration"
}
ok: [rocky-managed] => (item=Backup current state) => {
    "msg": "Step 2: Backup current state"
}
ok: [rocky-managed] => (item=Apply changes) => {
    "msg": "Step 3: Apply changes"
}
ok: [rocky-managed] => (item=Verify deployment) => {
    "msg": "Step 4: Verify deployment"
}

Another useful loop_control option: label. It controls what Ansible prints next to each iteration. Instead of dumping the entire dictionary, show just the key you care about: label: "{{ item.name }}". This cleans up verbose output significantly.

Loop Over a Dictionary with dict2items

The dict2items filter converts a dictionary into a list of key/value pairs for looping:

    - name: Loop over dictionary
      ansible.builtin.debug:
        msg: "Service {{ item.key }} listens on port {{ item.value }}"
      loop: "{{ services | dict2items }}"
      vars:
        services:
          nginx: 80
          postgresql: 5432
          redis: 6379

Each key/value pair becomes an iteration:

ok: [rocky-managed] => (item={'key': 'nginx', 'value': 80}) => {
    "msg": "Service nginx listens on port 80"
}
ok: [rocky-managed] => (item={'key': 'postgresql', 'value': 5432}) => {
    "msg": "Service postgresql listens on port 5432"
}
ok: [rocky-managed] => (item={'key': 'redis', 'value': 6379}) => {
    "msg": "Service redis listens on port 6379"
}

Retry Loops with until

Some tasks need to wait for a condition. A service might take a few seconds to start, or an API might return a 503 while initializing. The until keyword retries a task until its condition is true:

    - name: Retry until condition is met
      ansible.builtin.command: cat /proc/uptime
      register: uptime_check
      until: uptime_check.stdout.split('.')[0] | int > 0
      retries: 3
      delay: 2
      changed_when: false

    - name: Show retry result
      ansible.builtin.debug:
        msg: "Uptime: {{ uptime_check.stdout.split()[0] }}s (took {{ uptime_check.attempts }} attempt)"

The task succeeded on the first attempt since the server was already up:

ok: [rocky-managed] => {
    "msg": "Uptime: 1110.61s (took 1 attempt)"
}

A more practical use: waiting for a web service to respond after starting it. Set retries: 10 and delay: 5 to give a service up to 50 seconds to become healthy. The registered variable includes an .attempts attribute so you know how many tries it took.

Error Handling with block/rescue/always

Try/catch for Ansible. The block section runs tasks normally. If any task in the block fails, execution jumps to rescue. The always section runs regardless of success or failure.

    - name: Error handling with block/rescue/always
      block:
        - name: Try to read a nonexistent file
          ansible.builtin.command: cat /etc/nonexistent.conf
          register: file_content

        - name: This will be skipped because the previous task fails
          ansible.builtin.debug:
            msg: "File content: {{ file_content.stdout }}"

      rescue:
        - name: Handle the error gracefully
          ansible.builtin.debug:
            msg: "Expected error caught: file does not exist. Creating default..."

        - name: Create a fallback config
          ansible.builtin.copy:
            content: "# Default configuration\nport=8080\n"
            dest: /tmp/fallback.conf
            mode: "0644"
          become: true

      always:
        - name: This always runs
          ansible.builtin.debug:
            msg: "Cleanup complete. Block execution finished."

The block fails at the first task, rescue catches the error and creates a fallback config, and always runs the cleanup regardless:

TASK [Try to read a nonexistent file] *************
fatal: [rocky-managed]: FAILED! => {"stderr": "cat: /etc/nonexistent.conf: No such file or directory", "rc": 1}

TASK [Handle the error gracefully] ****************
ok: [rocky-managed] => {
    "msg": "Expected error caught: file does not exist. Creating default..."
}

TASK [Create a fallback config] *******************
changed: [rocky-managed]

TASK [This always runs] ***************************
ok: [rocky-managed] => {
    "msg": "Cleanup complete. Block execution finished."
}

PLAY RECAP ****************************************
rocky-managed  : ok=6  changed=1  rescued=1

The play recap shows rescued=1, confirming the error was handled. Without block/rescue, that fatal would have stopped the entire play. Common uses: rolling back a failed deployment, sending an alert on failure, or cleaning up temporary resources in always.

Practical Example: Cross-Platform Service Setup

Combining conditionals and loops solves the real challenge of managing mixed environments. This playbook installs OS-specific packages, starts the appropriate firewall, and handles the vim-enhanced vs vim package name difference that catches people when moving from RHEL to Debian:

---
- name: Cross-Platform Service Setup
  hosts: all
  become: true

  vars:
    common_packages:
      - curl
      - rsync
    rhel_packages:
      - vim-enhanced
      - policycoreutils-python-utils
      - firewalld
    debian_packages:
      - vim
      - ufw
      - apt-transport-https

  tasks:
    - name: Install common packages
      ansible.builtin.package:
        name: "{{ item }}"
        state: present
      loop: "{{ common_packages }}"

    - name: Install RHEL-specific packages
      ansible.builtin.dnf:
        name: "{{ item }}"
        state: present
      loop: "{{ rhel_packages }}"
      when: ansible_facts['os_family'] == "RedHat"

    - name: Install Debian-specific packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop: "{{ debian_packages }}"
      when: ansible_facts['os_family'] == "Debian"

    - name: Enable firewalld on RHEL
      ansible.builtin.systemd:
        name: firewalld
        state: started
        enabled: true
      when: ansible_facts['os_family'] == "RedHat"

Rocky installs its packages and skips the Debian ones, while Ubuntu does the reverse:

TASK [Install RHEL-specific packages] *************
skipping: [ubuntu-managed] => (item=vim-enhanced)
skipping: [ubuntu-managed] => (item=policycoreutils-python-utils)
skipping: [ubuntu-managed] => (item=firewalld)
ok: [rocky-managed] => (item=vim-enhanced)
ok: [rocky-managed] => (item=policycoreutils-python-utils)
ok: [rocky-managed] => (item=firewalld)

TASK [Install Debian-specific packages] ***********
skipping: [rocky-managed] => (item=vim)
skipping: [rocky-managed] => (item=ufw)
skipping: [rocky-managed] => (item=apt-transport-https)
ok: [ubuntu-managed] => (item=vim)
ok: [ubuntu-managed] => (item=ufw)
changed: [ubuntu-managed] => (item=apt-transport-https)

The when condition on a loop evaluates once per host, not once per item. If the condition is false, the entire loop is skipped. This is efficient because Ansible doesn’t even iterate the list on hosts where the condition doesn’t apply.

Ansible when conditionals running OS-specific tasks on Rocky Linux and Ubuntu
Conditionals in action: Rocky runs dnf tasks while Ubuntu runs apt tasks
Ansible loop output creating packages and users on Rocky Linux 10.1
Loops iterating over packages and user dictionaries on Rocky Linux 10.1

Quick Reference Table

PatternSyntaxWhen to Use
Simple conditionalwhen: ansible_facts['key'] == "value"OS branching, feature flags
AND conditionsYAML list under when:Multiple criteria must all match
OR conditionswhen: A or BAny one criterion is enough
Membership testwhen: var in [list]Checking supported values
Registered checkwhen: result.rc == 0Branching on command output
Simple looploop: [a, b, c]Same task, different values
Dict looploop: "{{ dict | dict2items }}"Key/value pair iteration
Indexed looploop_control: index_var: idxNumbered steps
Retry loopuntil: conditionWaiting for service readiness
Error handlingblock/rescue/alwaysGraceful failure recovery

For the complete Ansible learning path, see the Ansible Automation Guide. The Ansible Cheat Sheet covers quick command syntax, and the Ansible Roles tutorial shows how to package conditionals and loops into reusable roles.

Related Articles

Automation Setup GitLab Runner on Rocky Linux 10 / Ubuntu 24.04 Automation Install Semaphore Ansible UI on Ubuntu 24.04 / Debian 13 Automation Install GitLab CE on Ubuntu 26.04 LTS Chef Install Chef Development Kit / Workstation on Ubuntu 20.04|18.04

Leave a Comment

Press ESC to close