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.
Jump to a section
A simple way to picture Conditionals, Loops, and Task Control
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.
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:
- 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:
- name: Install required packages
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- httpd
- mariadb-server
- phpThe 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:
- 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.
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.
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.
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.
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.
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.
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.
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.
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:
- 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:
- 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:
- 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: localhostThe 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.
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:
when:
- ansible_facts['distribution_version'] == '8'
- ansible_facts['memtotal_mb'] >= 4096A 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.
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.
These come up on the exam all the time. Here's how to tell them apart.
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)
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
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
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.
Mistake
I need to wrap all variables in double curly braces inside a `when` clause.
Correct
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.
Mistake
A `failed_when` condition stops the playbook for that specific host.
Correct
`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.
Mistake
A `loop` over an empty list will cause an error.
Correct
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.
Mistake
`ignore_errors: yes` makes Ansible ignore all subsequent task failures on that host.
Correct
`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.
Mistake
The `block` and `rescue` keywords work across multiple hosts, like a try-catch in programming languages.
Correct
`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.
Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.
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"`.
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`.
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.
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`.
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.
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.
You've finished Conditionals, Loops, and Task Control. Continue through the EX294 study guide to build a complete picture of the exam.
Done with this chapter?