Red Hat Certified Engineer (RHCE) #9 Tags and conditionals: when, loop, until

9 min read

If #8 Error handling gave you a firm grip on dealing with failure, this time we organize the flow control that decides when a task runs, how many times it repeats, and which parts to run selectively. You set conditions with when, iterate over lists with loop, retry until success with until, and run only part of a playbook with tags. These four are tools that show up in nearly every RHCE hands-on exam.

when: conditional execution #

when is the key that decides whether a task runs. If the condition is true it runs; if false it’s skipped. The value is a Jinja2 expression, but inside when you write variable names directly, without {{ }}.

Basic when usage
- name: Install the Apache package
  ansible.builtin.dnf:
    name: httpd
    state: present
  when: ansible_facts['os_family'] == "RedHat"

On hosts where the condition is false, the task is shown as skipping and doesn’t affect idempotency.

Fact-based branching #

The most common pattern in the exam is branching that does different work depending on OS type or version. You use the values gathered into ansible_facts in the condition.

Fact-based branching
- name: Install apache2 on Debian-based systems
  ansible.builtin.apt:
    name: apache2
    state: present
  when: ansible_facts['os_family'] == "Debian"

- name: Run only on RHEL 9 or later
  ansible.builtin.debug:
    msg: "This is RHEL 9 or later"
  when:
    - ansible_facts['distribution'] == "RedHat"
    - ansible_facts['distribution_major_version'] | int >= 9

distribution_major_version is a string, so you convert it to an integer with the | int filter before comparing. Skip this conversion and the numeric comparison can go wrong.

Combining with and, or #

When you bundle multiple conditions there are two approaches. Listing them out means it runs when all conditions are true (and).

and conditions (list)
- name: When both conditions are true
  ansible.builtin.debug:
    msg: "Conditions met"
  when:
    - ansible_facts['os_family'] == "RedHat"
    - ansible_facts['memtotal_mb'] > 2048

You write or or compound logic as a single-line expression. Make precedence explicit with parentheses.

or condition (expression)
- name: When it's RHEL or Fedora
  ansible.builtin.debug:
    msg: "Red Hat family"
  when: >
    ansible_facts['distribution'] == "RedHat" or
    ansible_facts['distribution'] == "Fedora"

Checking whether a variable is defined #

Branching on whether a variable is defined is also frequent. You use is defined, is undefined, and is none.

Check if a variable is defined
- name: Install only when the extra_pkg variable is defined
  ansible.builtin.dnf:
    name: "{{ extra_pkg }}"
    state: present
  when: extra_pkg is defined

When branching on a value’s truthiness, you either leave the variable name as is or use the | bool filter, as in when: enable_service | bool.

loop: iterating over lists #

loop runs a single task repeatedly over each item of a list. The current item in the iteration is referenced with the item variable.

Basic loop usage
- name: Install multiple packages
  ansible.builtin.dnf:
    name: "{{ item }}"
    state: present
  loop:
    - httpd
    - mariadb-server
    - php

When a module takes a list directly — as with package installation — it’s more efficient to pass it all at once as name: "{{ packages }}", but loop shows its true worth when each item needs an independent call, as with user creation.

Bulk-creating a user list #

The exam staple of bulk-creating a user list is loop’s signature example. If you make each item a dict, you can access values inside the item with item.key.

Bulk user creation
- name: Create users in bulk
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  loop:
    - { name: alice, groups: wheel }
    - { name: bob, groups: developers }
    - { name: carol, groups: developers }

Putting the user list in a variable file and referencing it from loop keeps the body of the playbook clean.

Create users from a variable
- name: Create users defined as variables
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
    state: present
  loop: "{{ users }}"

with_items is the old notation #

Older playbooks used with_items. The behavior is the same, but loop is the recommended notation now; write new playbooks with loop.

Legacy with_items syntax
# Old notation (for reference)
- name: The with_items way
  ansible.builtin.dnf:
    name: "{{ item }}"
    state: present
  with_items: [httpd, vsftpd]

with_items differs subtly from loop in that it flattens the list one level. As long as you don’t deal with nested lists the result is the same, so on the exam just use loop consistently.

Iterating a dict: dict2items #

To iterate over a dict (a set of key-value pairs) rather than a list, you convert it to a list with the dict2items filter. After conversion, each item has item.key and item.value.

Loop with dict2items
- name: Iterate over a dict as items
  ansible.builtin.debug:
    msg: "{{ item.key }} = {{ item.value }}"
  loop: "{{ user_roles | dict2items }}"

If user_roles is a dict like the following, it iterates twice.

user_roles example
user_roles:
  alice: admin
  bob: developer

loop_control: loop_var and label #

When loops nest through a role or an include, both the inner and outer loop use item, causing a collision. In that case you change the loop variable name with loop_var under loop_control.

Rename loop variable
- name: Change the loop variable name
  ansible.builtin.user:
    name: "{{ user_item.name }}"
    state: present
  loop: "{{ users }}"
  loop_control:
    loop_var: user_item

Also, iterating over dict items prints the whole dict at length in the output, cluttering the log. Specifying only the value to display for each iteration with label keeps the output clean.

Clean output with label
- name: Specify an output label
  ansible.builtin.user:
    name: "{{ item.name }}"
    state: present
  loop: "{{ users }}"
  loop_control:
    label: "{{ item.name }}"

until: retries #

until runs a task repeatedly until a condition becomes true. You use it to wait for an external service to come up, or to retry an operation that fails intermittently. You set the maximum number of attempts with retries and the interval between attempts (in seconds) with delay.

Retry with until
- name: Wait for the service to respond
  ansible.builtin.uri:
    url: http://localhost:8080/health
    status_code: 200
  register: result
  until: result.status == 200
  retries: 5
  delay: 10

The task above attempts up to 5 times at 10-second intervals until the response is 200. If the condition never becomes true in the end, the task fails. The key point is that to use until you must capture the result with register and reference it in the condition. When you use a module like command for a query purpose — one that’s caught as changed every time — set changed_when: false alongside it to keep idempotency (you can see this in the combined example below).

tags: partial runs #

tags attach a name label to a task or play, so you can selectively run only the parts you need rather than the whole playbook. It’s useful for quickly running just a specific piece of a long playbook.

Tagging tasks
- name: Install Apache
  ansible.builtin.dnf:
    name: httpd
    state: present
  tags:
    - packages

- name: Deploy the Apache config
  ansible.builtin.template:
    src: httpd.conf.j2
    dest: /etc/httpd/conf/httpd.conf
  tags:
    - config

–tags and –skip-tags #

--tags runs only the tasks carrying the specified tag. --skip-tags, conversely, runs everything except the specified tag.

Run with --tags and --skip-tags
# Run only tasks tagged config
ansible-playbook site.yml --tags config

# Skip only the packages tag and run the rest
ansible-playbook site.yml --skip-tags packages

ansible-navigator uses the same options.

Run with ansible-navigator
ansible-navigator run site.yml -m stdout --tags config

To check which tags are defined, you use --list-tags.

List defined tags
ansible-playbook site.yml --list-tags

Play-level tags #

If you tag an entire play, all tasks in that play inherit the tag.

Play-level tags
- name: Configure the web server
  hosts: web
  tags:
    - web
  tasks:
    - name: Install httpd
      ansible.builtin.dnf:
        name: httpd
        state: present

always and never #

A task tagged always always runs, even when you specify a different tag with --tags. You can still exclude it with --skip-tags always. Conversely, a task tagged never is not run by default, and runs only when you explicitly name that tag or another tag on the task.

always and never tags
- name: Pre-check (always runs)
  ansible.builtin.debug:
    msg: "Playbook starting"
  tags:
    - always

- name: Print debug info (only when specified)
  ansible.builtin.debug:
    var: ansible_facts
  tags:
    - never
    - debug

Above, the debug task is skipped normally, but runs when you invoke it with --tags debug. It’s useful for keeping a heavy diagnostic task out of ordinary runs.

Combined example: OS branching + loop + retries #

Let’s gather everything so far into one playbook. We branch by OS family to install packages, bulk-create users, retry until the service is active, and tag each step.

Combined example playbook
---
- name: Configure the web server
  hosts: web
  become: true
  vars:
    web_users:
      - { name: deploy, groups: wheel }
      - { name: app, groups: web }
  tasks:
    - name: Startup notice (always runs)
      ansible.builtin.debug:
        msg: "Starting configuration"
      tags:
        - always

    - name: Install RHEL-family packages
      ansible.builtin.dnf:
        name: httpd
        state: present
      when: ansible_facts['os_family'] == "RedHat"
      tags:
        - packages

    - name: Create operations users
      ansible.builtin.user:
        name: "{{ item.name }}"
        groups: "{{ item.groups }}"
        state: present
      loop: "{{ web_users }}"
      loop_control:
        label: "{{ item.name }}"
      tags:
        - users

    - name: Start the service
      ansible.builtin.service:
        name: httpd
        state: started
        enabled: true
      tags:
        - service

    - name: Confirm the service is active
      ansible.builtin.command: systemctl is-active httpd
      register: httpd_state
      until: httpd_state.stdout == "active"
      retries: 5
      delay: 3
      changed_when: false
      tags:
        - service

Invoking this playbook with --tags users runs only the startup notice (always) and user creation.

Exam points #

  • when takes variable names without {{ }}. In fact-based branching, comparisons like ansible_facts['os_family'] and distribution_major_version | int are staples.
  • Write and as a list, or as a single-line expression. A list runs only when all conditions are true.
  • Use is defined to check whether a variable exists. Add | bool to judge a value’s truthiness.
  • loop’s current item is item, and you access a dict item as item.name and so on. Bulk-creating a user list is the signature exam pattern.
  • To iterate a dict, convert it with the dict2items filter and access item.key, item.value.
  • For nested loops, avoid variable collisions with loop_control.loop_var, and tidy the output with label.
  • until pairs with register. Set the number of retries and the interval with retries and delay. Add changed_when: false to a command used for a query.
  • Run partially with tags. Select with --tags, exclude with --skip-tags, and check with --list-tags. always runs always; never runs only when specified.

Wrap-up #

What this post locked in:

  • when. Conditional execution. Fact- and variable-based branching, and (list) and or (expression), is defined
  • loop. List iteration. The item reference, bulk user creation, dict2items, loop_var and label under loop_control
  • until. Retries. Waiting for a service to come up by combining register, retries, and delay
  • tags. Partial runs. --tags, --skip-tags, --list-tags, play-level tags, always and never

These four tools keep reappearing in the RHCSA automation posts (#14〜#17) for OS branching and bulk list processing. Get them in hand and the later posts come much easier.

Next: Ansible Vault #

Flow control is locked in. Now we move on to handling sensitive values like passwords and API keys that end up in a playbook, safely.

In #10 Ansible Vault: managing secrets, we’ll organize hands-on the exam-staple patterns: encrypting variables with ansible-vault, running playbooks with --ask-vault-pass and a vault password file, and managing plaintext and ciphertext separately.

X