Ansible Lesson 16 – Conditionals and Loops | Dataplexa
Section II · Lesson 16

Conditionals and Loops

In this lesson

when conditions Multiple conditions loop & with_items Loop over mappings Combining loops & conditions

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.

AND — list

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
OR — inline

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 — negation

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 — membership

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.

Native list (preferred for packages)
Single module call — faster, fewer SSH operations
Reports one changed / ok for all items combined
- name: Install packages
  ansible.builtin.package:
    name:
      - nginx
      - git
      - curl
    state: present
loop (use when items differ per iteration)
One module call per item — slower but granular
Reports individual 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.
A YAML list under 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.
Loop items can be simple strings or full mappings — use {{ item.key }} to reference individual keys when each iteration needs multiple values.
Use 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.
Prefer native list passing over 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.