# Conditionals, Loops, and Task Control

> Chapter 7 of the Courseiva REDHAT-RHCE curriculum — https://courseiva.com/learn/redhat-rhce/conditionals-loops-and-task-control

**Official objective:** 3.4 — Implement conditionals, loops, and task control in playbooks

## Introduction

Conditionals, loops, and task control are the decision-making and repetition machinery of Ansible playbooks. Without them, every automation would be rigid, performing the same actions regardless of the system's actual state — which is both inefficient and dangerous when you are managing hundreds of servers.

## The Coffee Shop Order Analogy

A coffee shop's daily operations are a perfect map for conditionals, loops, and task control in Ansible. The shop has a set of standard tasks: take order, brew coffee, serve coffee, clean up. But these tasks don't run the same way every single time. 

A conditional is like the barista checking a customer's order slip. If the slip says 'decaf', the barista uses decaf beans; if it says 'oat milk', they use oat milk. Without this check, every customer would get the same full-caffeine dairy latte — a disaster for allergies and preferences. The barista does not blindly execute every step; they ask 'What does the current state require?' before acting. In Ansible, this maps to the `when` statement: a task only runs if a certain condition is true on the target machine. 

A loop is the barista making five identical flat whites at once. Instead of reading the same recipe card five separate times and repeating the motions manually, they line up five cups and steam enough milk for all of them in one batch. This is far more efficient than performing the same motion five times with five separate recipe checks. In Ansible, a loop (`loop:` or `with_items:`) lets one task act on multiple items — installing three packages, creating five users, or copying ten files — without writing ten separate tasks. 

Task control is the shop's closing procedure. The barista does not start mopping the floor while a customer is still waiting for their espresso. They check 'Are all orders complete?' before starting the clean-up. If a customer asks for a refund, they must handle that error path — apologise, process refund, note the event — before resuming normal closing. In Ansible, this is error handling: `ignore_errors`, `failed_when`, and `block/rescue/always` structures. The barista (Ansible) does not crash the whole shop because one order went wrong; it handles the exception and keeps going or fails gracefully.

## Core explanation

An Ansible playbook is fundamentally a list of tasks executed in order on a target machine. But real-world automation requires flexibility: you may need to install a package only if it is not already present, create users from a list, or handle a step that fails without crashing the entire playbook. This is where conditionals, loops, and task control come in. 

**Conditionals** 
A conditional in Ansible is a `when` clause attached to a task. It evaluates a Jinja2 expression — a simple programming language inside double curly braces `{{ }}` — and only runs the task if the expression is true. For example, you might want to install the Apache web server only on Red Hat Enterprise Linux 9 machines: 

```yaml
- name: Install Apache on RHEL 9
  ansible.builtin.dnf:
    name: httpd
    state: present
  when: ansible_facts['distribution_version'] == '9'
```

The key concept here is `ansible_facts` — a collection of system information that Ansible gathers automatically before running tasks. It includes the OS family, IP address, memory size, and hundreds of other details. You can inspect any fact and use it as a condition. Common comparisons are string equality (`==`), numeric comparisons (`>=`, `<`), and boolean checks. You can also combine multiple conditions with `and` or `or`. 

The `when` clause works with any Ansible module. It is the primary way to make a playbook aware of its environment. Without conditionals, you would need separate playbooks for each OS version — a maintenance nightmare. 

**Loops** 
A loop lets a single task perform the same action on multiple items. There are several loop styles in Ansible: 

- The standard `loop:` keyword accepts a list. For example, to install three different packages: 

```yaml
- name: Install required packages
  ansible.builtin.dnf:
    name: "{{ item }}"
    state: present
  loop:
    - httpd
    - mariadb-server
    - php
```

The special variable `{{ item }}` takes the value of each list element in turn. Ansible runs the task once per item, so this one task becomes three installations. 
- `with_items:` is an older syntax, still supported, that works identically. 
- `with_dict:` loops over key-value pairs in a dictionary (useful for configuring multiple users with different details). 
- `with_fileglob:` loops over a set of files matching a pattern. 

Loops dramatically reduce playbook size and complexity. Instead of writing 50 identical tasks to create 50 users, you write one task with a 50-item loop. They make playbooks easier to read and maintain. 

**Task Control** 
Task control refers to structures that manage the flow of a playbook based on task outcomes. The primary tools are: 

- `ignore_errors` — a directive that tells Ansible to continue running the playbook even if this specific task fails. The task is marked as failed, but execution does not stop. Use it cautiously, because hiding errors can mask real problems. 
- `failed_when` — a condition that causes a task to be considered failed when the condition is true, even if the command itself succeeded. For example, you might run a script that always returns exit code 0 but outputs an error message; you can check the output string and mark the task failed if it contains 'ERROR'. 
- `changed_when` — manually declare whether a task changed the system. Normally, Ansible determines 'changed' based on the module's output. Override this with `changed_when: false` to suppress the 'changed' status for informational commands like `debug`. 
- `block` and `rescue` — a try/catch pattern. A `block` groups several tasks. If any task inside the block fails, execution jumps to the `rescue` section, which contains recovery tasks. After `rescue`, you can have an `always` section that runs regardless of success or failure, perfect for cleanup actions. This is the most robust way to handle errors in a multi-step process. 

For example: 

```yaml
- name: Attempt database update
  block:
    - name: Run migration script
      ansible.builtin.shell: /opt/update_db.sh
    - name: Verify connection
      ansible.builtin.shell: /opt/check_db.sh
  rescue:
    - name: Rollback last change
      ansible.builtin.shell: /opt/rollback.sh
  always:
    - name: Log action
      ansible.builtin.debug:
        msg: "Database update attempted"
```

This structure ensures that if the migration fails, a rollback is automatically triggered, and the attempt is always logged. Without `block/rescue`, a failure would stop the playbook or require complex condition chains. 

**Why these concepts matter** 
Before Ansible, system administrators wrote shell scripts or used configuration management tools like Puppet or Chef. Conditionals and loops existed in those tools, but Ansible makes them accessible through simple YAML syntax — no programming language required. The `when` clause, loop keywords, and control structures allow a single playbook to handle a heterogeneous environment of different operating systems, different states, and different failure modes. For the EX294 exam, you must be able to write a playbook that conditionally applies configuration, iterates over lists, and handles errors properly. These three mechanisms are the difference between a brittle script and a robust automation.

## Real-world context

Imagine you are the sole IT administrator for a midsized e-commerce company with 300 servers. Fifty are web servers (RHEL 8), 150 are application servers (RHEL 9), and 100 are database servers (RHEL 8 with older hardware). Your manager needs you to deploy a new monitoring agent to all servers, but with specific requirements: 

- The agent must be installed on every server, but the configuration file differs between web, app, and DB tiers. 
- Web servers need a higher logging level for security audits. 
- Database servers require an extra performance-monitoring plugin that is incompatible with older hardware, so you must skip those 20 older DB servers. 
- If the agent installation fails on any server, you need to log which server failed and then continue with the others — do not stop the whole rollout. 

Here is how you would solve this using conditionals, loops, and task control: 

First, you write a playbook with a loop over `all` servers. Use Ansible's `hosts: all` to target the entire inventory. The inventory already groups servers (web, app, db). 

Second, you use a conditional to decide which configuration file to deploy. Create a `when` clause that checks the server's group membership using `inventory_hostname` or group variables: 

```yaml
- name: Deploy monitoring config
  ansible.builtin.template:
    src: "monitor_{{ group_names[0] }}.conf.j2"
    dest: /etc/monitor/monitor.conf
  when: "'web' in group_names or 'app' in group_names"
```

Third, for the database servers with older hardware, use another conditional: 

```yaml
- name: Install performance plugin on DB servers
  ansible.builtin.dnf:
    name: monitor-perf-plugin
    state: present
  when: 
    - "'db' in group_names"
    - "ansible_facts['memtotal_mb'] > 8192"
```

This checks that the server is in the DB group and has more than 8 GB of RAM. If not, the plugin is simply skipped. 

Fourth, for error handling, wrap the installation tasks in a `block` with `rescue`: 

```yaml
- name: Install monitoring agent
  block:
    - name: Install agent package
      ansible.builtin.dnf:
        name: monitoring-agent
        state: present
    - name: Start agent service
      ansible.builtin.service:
        name: monitoring-agent
        state: started
  rescue:
    - name: Log failure
      ansible.builtin.copy:
        content: "{{ inventory_hostname }} failed on {{ ansible_date_time.date }}"
        dest: /var/log/monitor_failures.log
      delegate_to: localhost
```

The `rescue` block logs the hostname and date to a local file on your control node, and the playbook continues to the next server. 

Finally, you run the playbook with `ansible-playbook deploy_monitor.yml`. The output shows which servers succeeded, which were skipped (older DB servers), and which had errors logged. Your manager has a single run that handled 300 heterogeneous servers without manual intervention. 

This real-world scenario demonstrates how conditionals prevent incorrect configs, loops make the playbook concise, and task control ensures the automation is resilient. Without these features, you would need hundreds of separate playbooks or fragile scripts.

## Exam focus

The EX294 exam tests conditionals, loops, and task control in several direct ways. You must be able to read and write playbook snippets using these features. The exam is practical — you are given a requirement and must select the correct YAML block from multiple choices, or complete a playbook by adding the missing line. 

**Key exam topics:**

- **Conditionals with `when`:** You will be asked to write or identify a `when` clause that uses an Ansible fact. Common traps: forgetting the double curly braces `{{ }}` around the variable when the condition is a comparison (but actually, inside `when`, you rarely need braces for simple variable references — use `when: ansible_facts['os_family'] == 'RedHat'` without braces around the variable name). However, if you use a registered variable, you often need `when: result.rc != 0`. The exam will test your ability to distinguish these cases. 
- **Multiple conditions:** They will give a scenario like 'run this task only on RHEL 8 machines that have at least 4 GB RAM'. You must combine conditions with `and` or a list: 

```yaml
when:
  - ansible_facts['distribution_version'] == '8'
  - ansible_facts['memtotal_mb'] >= 4096
```

A list of conditions is an implicit `and`. Using `or` requires the `or` keyword within a single expression. 
- **Loops:** The exam tests `loop:` and `with_items:`. They will give a list of four packages or five users and ask which loop construct correctly iterates over them. They also test the `{{ item }}` variable. A common trap is confusing `loop` with `with_nested` or `with_together` — you only need the basic `loop` for most questions. 
- **Task control with `ignore_errors` and `failed_when`:** You might see a task that runs a shell command and always returns exit code 1. The question: 'How do you prevent this from stopping the playbook?' Answer: either `ignore_errors: yes` or `failed_when: false`. But they will distinguish: `failed_when` is more precise because it can look at output, while `ignore_errors` is a blanket override. The exam expects you to know that `ignore_errors` changes the task's status to 'failed' but continues, whereas `failed_when: false` means the task never shows as failed. 
- **Block/rescue/always:** You may be given a three-task sequence and asked to wrap it in a block with a rescue that undoes a previous change. The exam loves this because it tests understanding of error-handling flow. 
- **Variables in conditions:** They will test registered variables. For example: register the result of a command, then use `when: result.stdout == 'success'`. The key is that registered variables are dictionaries. 

**Traps to avoid:** 
- Forgetting that `when` expects a Jinja2 expression but without the surrounding `{{ }}` for simple comparisons. The exam often includes a distractor that wraps the entire condition in braces — that is incorrect. 
- Using `==` when comparing strings — legitimate. Using `is defined` is a common search pattern; the exam tests `when: myvar is defined`. 
- Misunderstanding that `loop` creates an implicit `item` variable; if you nest loops, you must rename with `loop_control` and `loop_var`. The exam may test simple nesting with `subelements` or `with_nested`. 
- Overusing `ignore_errors` instead of proper `rescue`. The exam rewards precise error handling. 

**Key definitions to memorise:** 
- `when`: runs a task conditionally. 
- `loop`: repeats a task for each item in a list. 
- `{{ item }}`: the current element within a loop. 
- `block`: groups tasks for error handling. 
- `rescue`: runs if any task in the block fails. 
- `always`: runs after block or rescue, regardless of success. 
- `ignore_errors`: continues playbook after task failure. 
- `failed_when`: custom failure condition. 
- `changed_when`: custom changed status.

## Step by step

1. **Define the condition** — Identify the exact fact or variable you need to check. For example, `ansible_facts['distribution']` or a registered variable. Write the `when` clause as a single expression or a list of expressions. This step is critical because an incorrect condition leads to tasks running on the wrong hosts or not running at all.
2. **Select the loop style** — Choose between `loop:` (preferred for simple lists), `with_items:`, or specialised loops like `with_dict:`. The loop list can be hardcoded, a variable, or the output of a filter. The `{{ item }}` variable is automatically assigned for each iteration. Incorrect selection can cause syntax errors or unexpected behaviour with nested data.
3. **Wrap error-prone tasks in a block** — If a series of tasks are interdependent (e.g., install, configure, start), wrap them in a `block` directive. This ensures that if any task fails, the entire block is considered failed and the corresponding `rescue` section runs. This step is essential for multi-step operations where partial failure is catastrophic.
4. **Write the rescue actions** — In the `rescue` section, define tasks that undo or compensate for the failure. Common actions include rolling back configuration, logging the error, or sending an alert. The rescue runs only on the host that failed. Without this step, a failure in the block would leave the system in an inconsistent state.
5. **Add an always section for cleanup** — Include an `always` block after `rescue` to perform actions that must run regardless of success or failure. Examples: removing temporary files, closing a database connection, or recording the attempt in a log. This ensures that even if the rescue fails, critical cleanup still happens.
6. **Test with a single host first** — Before running the playbook against your entire inventory, test it on a single representative host using `ansible-playbook -l hostname`. Verify that conditionals skip correctly, loops iterate as expected, and error handling works. This step prevents widespread damage from logic errors.
7. **Check the output for skipped and failed hosts** — After a full run, review the Ansible output summary: 'ok', 'changed', 'failed', and 'skipped' counts. Conditionals that unexpectedly skip or run indicate logic flaws. Tasks that fail but should not (or succeed but should fail) point to incorrect `failed_when` or `ignore_errors` usage. This step validates your entire control flow.

## Comparisons

### when (Conditional) vs block/rescue (Task Control)

**when (Conditional):**
- Decides whether a single task runs based on a condition before execution.
- Does not handle errors during the task; it only gates execution.
- Evaluated at runtime for each host; can use facts or variables.

**block/rescue (Task Control):**
- Groups multiple tasks and handles errors that occur during their execution.
- Provides a separate recovery path (rescue) without needing a condition.
- Manages flow after a task fails, not before it runs.

### loop (Iteration) vs with_items (Iteration)

**loop (Iteration):**
- Modern, recommended keyword introduced in Ansible 2.5.
- Accepts any list or array directly; supports complex loops with `loop_control`.
- More consistent syntax across different loop types.

**with_items (Iteration):**
- Legacy keyword, still supported but not recommended for new playbooks.
- Primarily designed for simple lists; behaviour identical to `loop` for those cases.
- Cannot be easily extended with `loop_control` features like index tracking.

### ignore_errors vs failed_when

**ignore_errors:**
- Blanket directive: continues playbook regardless of task failure.
- Does not change the task's 'failed' status; it just suppresses stopping.
- Useful for non-critical tasks where failure is acceptable.

**failed_when:**
- Custom failure condition: defines precisely when a task should be considered failed, even if exit code is 0.
- Can mark a task as failed based on output content, not just return code.
- More precise — you can allow certain errors while catching specific ones.

### rescue vs always

**rescue:**
- Runs only if a task in the preceding block fails.
- Intended for recovery or rollback actions.
- Does not run if the block succeeds.

**always:**
- Runs after block or rescue, regardless of success or failure.
- Intended for cleanup actions (delete temp files, close connections).
- Always executes, even if rescue itself fails.

## Diagram

_Flowchart showing how Ansible processes conditionals, loops, and error handling in a playbook._

```mermaid
flowchart TD
    A[Start Playbook] --> B{Check Condition}
    B -->|Condition True| C[Run Task]
    B -->|Condition False| D[Skip Task]
    C --> E{Error in Task?}
    E -->|No Error| F[Next Task]
    E -->|Error| G[Rescue Block]
    G --> H[Always Block]
    H --> F
    D --> F
    F --> I{Loop Items Left?}
    I -->|Yes| J[Next Item as {{ item }}]
    J --> C
    I -->|No| K[End Play]
    K --> L[Summary: ok, changed, failed, skipped]
```

## Common misconceptions

- **Misconception:** I need to wrap all variables in double curly braces inside a `when` clause. **Reality:** Inside a `when` clause, Ansible automatically evaluates Jinja2 expressions. You do NOT use `{{ }}` around the variable itself; you use just the variable name. Use `{{ }}` only if you are combining strings or formatting output. For example: `when: ansible_os_family == 'RedHat'` is correct; `when: {{ ansible_os_family }} == 'RedHat'` is wrong. (Many beginners come from templating contexts where `{{ }}` is always needed, and tutorials sometimes show inconsistent examples, causing confusion.)
- **Misconception:** A `failed_when` condition stops the playbook for that specific host. **Reality:** `failed_when` causes Ansible to treat the task as failed for that host, but the playbook continues to run on other hosts unless you have set `any_errors_fatal`. The host that failed will be excluded from subsequent tasks in the play (unless you use `ignore_errors`). It does not stop the entire playbook run. (Beginners often think 'failed' means 'abort immediately' because in many scripting languages, a non-zero exit code halts execution. Ansible is designed to handle multiple hosts independently by default.)
- **Misconception:** A `loop` over an empty list will cause an error. **Reality:** A `loop` over an empty list simply skips the task — Ansible will show the task as 'skipped' with 0 iterations. No error occurs. The task runs zero times, which is useful when the list is dynamically populated. (People assume 'doing nothing' is an error because they think the playbook expects at least one item. Ansible is resilient; an empty loop is a deliberate design choice to avoid conditional clutter.)
- **Misconception:** `ignore_errors: yes` makes Ansible ignore all subsequent task failures on that host. **Reality:** `ignore_errors` only applies to the specific task to which it is attached. If a later task in the playbook fails, it will be treated normally — unless that task also has `ignore_errors`. It does not create a global 'ignore everything' state. (The name sounds like a global setting. Beginners assume it sets a flag that persists, similar to 'set -e' in bash, but it is per-task.)
- **Misconception:** The `block` and `rescue` keywords work across multiple hosts, like a try-catch in programming languages. **Reality:** `block` and `rescue` work per-host, per-play. Each host executes its own block independently. If one host fails in the block, only that host's rescue runs. Other hosts are unaffected. It is not a global try-catch for the entire playbook. (In languages like Python, a try-catch is a single flow of control. Ansible's model is parallel execution across hosts, which is fundamentally different.)

## Key takeaways

- A `when` clause without curly braces around simple variables is the correct syntax for conditionals.
- A `loop` repeats a task for each item in a list; the current element is always `{{ item }}`.
- A `block` groups tasks and, with `rescue`, provides per-host error recovery without stopping the playbook.
- `failed_when` lets you define custom failure logic based on a task's output, not just its return code.
- Use `ignore_errors` sparingly because it hides task failures that might indicate real problems.
- The `always` section of a block runs regardless of success or failure, making it ideal for cleanup tasks.
- An empty `loop` list is valid and causes the task to be skipped silently.
- Combining multiple conditions in a list inside `when` is an implicit AND; use the `or` keyword for OR logic.

## FAQ

**Can I use a variable inside a `when` condition without braces?**

Yes, you can use a variable name directly inside `when` without `{{ }}`. For example, `when: my_var` checks if `my_var` is truthy (defined and not null, not false, not empty string). Use braces only when you need to combine with a string literal, like `when: "'web' in {{ group_names }}"` is wrong; correct is `when: "'web' in group_names"`.

**What is the difference between `loop` and `with_items` in Ansible?**

Functionally, `loop` and `with_items` are identical for simple lists. `loop` is the newer, recommended syntax and accepts any list. `with_items` is the older style and also accepts a list. The exam accepts both, but you should use `loop` in new playbooks as it is more consistent with other loop types like `with_dict`.

**Does `block/rescue` work across different hosts?**

No, `block/rescue` works per-host. If host A's block fails, only host A's rescue runs. Host B's block continues independently. This ensures that a failure on one server does not affect others, which is essential for parallel execution.

**How do I fail a task based on command output, not exit code?**

Use `failed_when` with a condition that checks the output. For example: `failed_when: result.stdout is search('ERROR')`. This overrides the default behaviour where exit code 0 means success. You must first register the command output with `register: result`.

**Can I use `ignore_errors` and `failed_when` together?**

It is possible but not recommended. `ignore_errors: yes` tells Ansible to continue regardless of the task's final status, even if `failed_when` marks it as failed. Using both is confusing and usually indicates a design issue. Prefer one or the other.

**What happens if a `rescue` block itself fails?**

If a task inside a `rescue` block fails, the playbook proceeds to the `always` block (if present) and then the host is marked as failed. The playbook does not try to rescue the rescue. This is why you should keep rescue tasks simple — idempotent operations like copying a backup config file.

## Check your understanding

1. **In a playbook, you want to install a package only on servers running RHEL 8. Which `when` clause is correct?**

   Answer: `when: ansible_facts['distribution_version'] == '8'` (without `{{ }}` around the variable).

2. **You have a task that registers a variable `result` from a command. How do you fail the task only if the output contains 'ERROR'?**

   Answer: Use `failed_when: result.stdout is search('ERROR')`.

3. **What is the purpose of the `always` section inside a `block` structure?**

   Answer: The `always` section runs tasks that must execute regardless of whether the block succeeded or the rescue was invoked, typically for cleanup.

4. **How does Ansible behave if a `loop` is provided an empty list?**

   Answer: The task is skipped — Ansible shows it as skipped with zero iterations, no error occurs.

5. **You need to run a task only when two conditions are both true: the host is in the 'web' group and the OS is RHEL. How do you structure the `when` clause?**

   Answer: Use a list: `when: - "'web' in group_names" - ansible_facts['os_family'] == 'RedHat'`

---

Interactive version with quiz and diagrams: https://courseiva.com/learn/redhat-rhce/conditionals-loops-and-task-control
