Ansible Course
Conditionals and Loops
In this lesson
Conditionals and loops are
the two mechanisms that make Ansible playbooks adaptive and concise. A
conditional — expressed with the when attribute — allows
a task to run only when a specific condition is true, enabling a single playbook to
behave differently depending on the OS, the value of a variable, or the result of a
previous task. A loop — expressed with the loop attribute
— runs the same task multiple times with different input values, replacing repetitive
task blocks with a single, clean definition. Together they are the primary tools for
writing automation that is both general enough to reuse and precise enough to be
correct on every individual host.
The when Condition
The
when
attribute accepts a Jinja2 expression that evaluates to true or
false. When the expression is true, the task runs. When it
is false, the task is skipped and reported as skipped in
the output. Unlike Jinja2 expressions in other contexts, the value of when
is not wrapped in {{ }} — Ansible evaluates it as a
Jinja2 expression automatically.
---
- name: Conditional task examples
hosts: all
become: true
tasks:
# Condition based on a fact — check OS family
- name: Install Apache on Debian systems
ansible.builtin.apt:
name: apache2
state: present
when: ansible_os_family == "Debian" # no {{ }} needed in when
# Condition based on a variable
- name: Enable debug logging
ansible.builtin.lineinfile:
path: /etc/app/config.ini
line: "log_level=debug"
when: enable_debug | bool # cast to bool — handles string "true"
# Condition based on a registered variable
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/app/config.yml
register: config_check
- name: Create default config only if missing
ansible.builtin.copy:
src: default_config.yml
dest: /etc/app/config.yml
when: not config_check.stat.exists # run only when file does NOT exist
# Condition based on OS version
- name: Install Python 3.11 on Ubuntu 22.04+
ansible.builtin.apt:
name: python3.11
state: present
when:
- ansible_distribution == "Ubuntu"
- ansible_distribution_version is version("22.04", ">=")
# multiple conditions — all must be true
Multiple Conditions
Ansible supports all standard boolean
operators for combining conditions. Understanding how each form is evaluated prevents
subtle logic bugs — particularly the difference between a YAML list of conditions
(which uses AND) and an inline or expression.
YAML list → implicit AND
A list under when: means ALL conditions must be true. This is the most
common pattern and the most readable.
when: - ansible_os_family == "Debian" - ansible_memtotal_mb >= 2048
Inline or → either condition
Use the or keyword inline when either condition satisfying is enough
to run the task.
when: > ansible_distribution == "Ubuntu" or ansible_distribution == "Debian"
not prefix inverts the condition
Prefix any expression with not to run only when the condition is false.
Most commonly used with registered variables.
when: not config_file.stat.exists when: result.rc != 0
in tests list membership
Check whether a value is in a list. Cleaner than a chain of
or comparisons when checking against multiple values.
when: > ansible_distribution in ["Ubuntu", "Debian", "Mint"]
The Smart Thermostat Analogy
A smart thermostat uses conditionals and loops continuously. It checks the current temperature (fact), compares it to the target (condition), and turns the heating on or off (task) — then waits and repeats (loop) for every room in the house. Your playbook does the same: gather facts, evaluate conditions per host, run the right task, move to the next host. The power is that one set of rules governs every room, intelligently.
The loop Attribute
The
loop
attribute accepts a list and runs the task once for each item. The current item is
always available as {{ item }}. Loops replace blocks of near-identical
tasks — instead of five separate install tasks for five packages, one task with a
loop is cleaner, faster to read, and easier to extend.
Pattern 1 — Loop over a simple list
# Without loop — repetitive and hard to maintain
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Install git
ansible.builtin.package:
name: git
state: present
- name: Install curl
ansible.builtin.package:
name: curl
state: present
# With loop — clean, one task, easy to extend
- name: Install required packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- git
- curl
- python3-pip
- ufw
TASK [Install required packages] ********************************************** changed: [web01.example.com] => (item=nginx) ok: [web01.example.com] => (item=git) <-- already installed changed: [web01.example.com] => (item=curl) changed: [web01.example.com] => (item=python3-pip) ok: [web01.example.com] => (item=ufw) <-- already installed
What just happened?
Each loop iteration runs the module independently
and reports its own status. Items that were already installed report ok.
Items that needed installing report changed. Idempotency works
per-item — not per-loop. This is why using a dedicated module like
ansible.builtin.package in a loop is always preferable to passing a
list to a shell command.
Pattern 2 — Loop over a variable defined in vars or group_vars
---
- name: Install packages from variable list
hosts: webservers
vars:
web_packages:
- nginx
- certbot
- python3-certbot-nginx
tasks:
- name: Install web server packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop: "{{ web_packages }}" # reference the variable — loop over it
Looping Over Mappings
A loop item does not have to be a simple
string — it can be a mapping (dictionary) with multiple keys. Reference individual
keys as {{ item.keyname }}. This pattern is essential for tasks that
need more than one value per iteration — creating users with different shells,
deploying files with different destinations, or creating directories with different
permissions.
- name: Create application users with specific properties
ansible.builtin.user:
name: "{{ item.name }}"
shell: "{{ item.shell }}"
groups: "{{ item.groups }}"
state: present
loop:
- { name: deploy, shell: /bin/bash, groups: "sudo,www-data" }
- { name: appuser, shell: /bin/bash, groups: "www-data" }
- { name: monitor, shell: /bin/false, groups: "" }
---
# A cleaner multi-line format for complex loop items
- name: Create required directories with correct permissions
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
mode: "{{ item.mode }}"
loop:
- path: /var/www/app
owner: www-data
mode: "0755"
- path: /var/log/app
owner: syslog
mode: "0775"
- path: /etc/app
owner: root
mode: "0700"
TASK [Create required directories with correct permissions] *******************
changed: [web01.example.com] => (item={'path': '/var/www/app', 'owner': 'www-data', 'mode': '0755'})
changed: [web01.example.com] => (item={'path': '/var/log/app', 'owner': 'syslog', 'mode': '0775'})
changed: [web01.example.com] => (item={'path': '/etc/app', 'owner': 'root', 'mode': '0700'})Loop Control
The loop_control attribute
gives you fine-grained control over how a loop executes and how it appears in output.
These options are particularly useful when iterating over sensitive data or when the
default output is too verbose for large loops.
loop_control options
label
Replaces the full
item value in output with a shorter label. Essential when looping over complex
mappings or sensitive data — prevents cluttered or leaked output.
loop_var
Renames the loop
variable from item to a custom name. Required when nesting loops
— without it, both inner and outer loops use item and collide.
index_var
Exposes the
current loop index (0-based) as a variable. Useful for generating sequential
names, numbered config sections, or port assignments.
pause
Pauses for N
seconds between each loop iteration. Useful for rate-limited APIs, staged
deployments, or service restarts that need time to become healthy before
the next iteration.
- name: Deploy SSL certificates (suppress sensitive output)
ansible.builtin.copy:
content: "{{ item.cert }}"
dest: "/etc/ssl/certs/{{ item.domain }}.pem"
mode: "0644"
loop: "{{ ssl_certs }}"
loop_control:
label: "{{ item.domain }}" # output shows domain name, not the cert content
- name: Create numbered worker configs
ansible.builtin.template:
src: worker.conf.j2
dest: "/etc/app/worker-{{ worker_index }}.conf"
loop: "{{ worker_configs }}"
loop_control:
loop_var: worker_config # rename item to worker_config for clarity
index_var: worker_index # expose the 0-based index
pause: 2 # wait 2 seconds between each worker restart
Combining Loops and Conditions
Loops and conditions compose naturally —
you can add a when clause to a looping task. The condition is evaluated
once per iteration, against the current item value and all available
facts and variables. This enables selective processing of a list based on each item's
properties.
The scenario: A team manages a mixed fleet of Ubuntu and CentOS servers. Their package list contains entries for both OS families — each tagged with which family it applies to. One loop with a condition installs the right packages on each host automatically.
---
- name: Install OS-specific packages across a mixed fleet
hosts: all
become: true
vars:
packages:
- name: nginx
family: Debian
- name: nginx
family: RedHat
- name: python3-pip
family: Debian
- name: python3-pip
family: RedHat
- name: python3-certbot-nginx
family: Debian # certbot package name differs by OS
- name: python3-certbot
family: RedHat
tasks:
- name: Install packages matching this host's OS family
ansible.builtin.package:
name: "{{ item.name }}"
state: present
loop: "{{ packages }}"
when: item.family == ansible_os_family # evaluate per iteration
loop_control:
label: "{{ item.name }} ({{ item.family }})"
# On an Ubuntu host (Debian family): TASK [Install packages matching this host's OS family] ************************ changed: [ubuntu01] => (item=nginx (Debian)) skipped: [ubuntu01] => (item=nginx (RedHat)) <-- wrong family, skipped changed: [ubuntu01] => (item=python3-pip (Debian)) skipped: [ubuntu01] => (item=python3-pip (RedHat)) <-- wrong family, skipped changed: [ubuntu01] => (item=python3-certbot-nginx (Debian)) skipped: [ubuntu01] => (item=python3-certbot (RedHat)) <-- wrong family, skipped # On a CentOS host (RedHat family): skipped: [centos01] => (item=nginx (Debian)) <-- wrong family, skipped changed: [centos01] => (item=nginx (RedHat)) skipped: [centos01] => (item=python3-pip (Debian)) changed: [centos01] => (item=python3-pip (RedHat)) skipped: [centos01] => (item=python3-certbot-nginx (Debian)) changed: [centos01] => (item=python3-certbot (RedHat))
What just happened?
The same task ran on both host types. On the
Ubuntu host, every RedHat-tagged item was skipped; on the CentOS host,
every Debian-tagged item was skipped. One play, one task, zero
duplication — yet each host got exactly the right packages. This is the power of
combining loops with conditions: you define the full picture in one place and let
Ansible filter appropriately per host.
Loop vs Native Module List Passing
Some modules — notably
ansible.builtin.package and ansible.builtin.apt — accept
a list natively for their name parameter. This is faster than a loop
because the module processes all items in a single invocation rather than one SSH
operation per item. Know when to use each approach.
changed / ok
for all items combined- name: Install packages
ansible.builtin.package:
name:
- nginx
- git
- curl
state: present
changed /
ok per item — better for mixed state visibility- name: Install packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop: "{{ package_list }}"
Never Use with_items in New Playbooks
with_items is the
legacy loop syntax from Ansible versions before 2.5. It still works but is deprecated
and will eventually be removed. Always use loop: in new playbooks —
it is more powerful, supports all loop plugins, and works identically for the most
common use cases. When you encounter with_items in existing playbooks,
replacing it with loop: is a straightforward one-line change that
modernises the code without altering behaviour.
Key Takeaways
when does not use {{ }} — Ansible
evaluates the value as a Jinja2 expression automatically. Adding double
braces is a common mistake that produces unexpected results.
when: is an implicit AND
— all items in the list must be true for the task to run. Use the inline
or keyword when you need either-or logic.
{{ item.key }} to reference individual keys when each
iteration needs multiple values.
loop_control.label for sensitive or verbose loops
— it replaces the full item value in output with a cleaner label, preventing
certificate content, passwords, or overly long mappings from cluttering
the play output.
loop for package installs
— passing a list directly to ansible.builtin.package is faster
because it installs all packages in one module invocation rather than one
per loop iteration.
Teacher's Note
Take the Lesson 13 webserver
playbook and refactor the package install task to use a loop with a
variable defined in group_vars/. Then add a when
condition that skips the task on non-Debian hosts. You will use this exact
pattern in every real project from here on.
Practice Questions
1. When using loop: in
a task, what is the name of the built-in variable that holds the current
iteration's value?
2. When when: is given
a YAML list of conditions, are they combined with AND or OR logic?
3. Which task attribute contains
options like label, loop_var, and index_var
that control loop execution and output?
Quiz
1. A task has both a
loop: and a when: clause that references
item.family. How does Ansible evaluate the condition?
2. You are using an
include_tasks inside a loop, and the included task file also
uses a loop. Both loops use item by default. What happens and
how do you fix it?
3. You need to install five packages and none of them require per-item conditions. What is the most efficient approach?
Up Next · Lesson 17
Templates with Jinja2
Learn to generate dynamic configuration files using Jinja2 templates — the most powerful way to produce server-specific configs from a single source of truth.