Learn Ansible - Control Flow (Conditionals & Loops)
Episode 9 of 31

Learn Ansible - Control Flow (Conditionals & Loops)

Learn logic branching with when, modern looping with loop, and retry loops with until so your Ansible playbooks are adaptive to the diverse server conditions of the real world.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After episode 8, where we covered generating dynamic configuration files using Jinja2 templating and filters, in this episode we go one level up: creating logic inside the playbook itself. If in the previous episode we made data dynamic, now we'll make behavior dynamic.

You may have experienced this in the real working world: one identical playbook has to run across dozens of servers with different operating systems — some Ubuntu, some Rocky Linux, and some in staging and production environments. If the playbook is written rigidly, you'll end up creating several nearly identical playbook versions that duplicate each other and are hard to maintain. This is where conditionals and loops come in: both allow one identical playbook to adapt to each server's condition and run many tasks repeatedly without rewriting code.

In this episode, we'll cover two core control flow capabilities in Ansible: conditioning using the when keyword for logic branching, and looping using the loop keyword along with advanced variants like until for retries. We'll also cover common mistakes that often trap beginners, so you can write correct playbooks from the start.

Main Discussion

Conditionals

Imagine you're cooking with a recipe that includes the step "if you don't have tomato sauce, use chili sauce". A good recipe adapts to the ingredients available. Likewise, a good playbook must adapt to the condition of the target server. In Ansible, this logic branching is handled by the when keyword, which can be attached at the task, block, or play level.

The when Keyword: The Basis of Every Task Decision

The when keyword accepts a Jinja2 expression whose evaluation result must be boolean. If the expression is true, the task runs; if false, the task is skipped and shown as SKIPPED in the output. Look at this simple example:

restart-service.yml
- name: Restart service aplikasi
  hosts: all
  become: true
  tasks:
    - name: Restart service hanya di production
      ansible.builtin.systemd_service:
        name: app
        state: restarted
      when: env == "production"

If you run this playbook with --extra-vars "env=staging" or the env variable has any value other than production, the task above won't execute at all. The value tested by when doesn't always have to come from a variable — you can also test facts (the system data Ansible gathers, which we covered in episode 7), the results of other registered tasks, or inventory attributes. For example, testing the OS family:

cek-os-family.yml
- name: Cek family OS semua server
  hosts: all
  tasks:
    - name: Tampilkan family OS
      ansible.builtin.debug:
        msg: "OS family server ini adalah {{ ansible_facts['os_family'] }}"

Combining Conditions: and, or, and not

In real systems, a decision rarely depends on just one condition. Ansible provides Jinja2 logic operators that you can combine freely. Here's a summary of the most commonly used operators:

OperatorFunctionExample
andAll conditions must be truewhen: ansible_facts['os_family'] == "Debian" and env == "production"
orOne true condition is enoughwhen: env == "staging" or env == "production"
notReverses the evaluation resultwhen: not maintenance_window
()Groups logic prioritywhen: (env == "staging" or env == "production") and not maintenance_window

Same as mathematics, parentheses are very important when conditions get complex. Without parentheses, and and or combinations can be evaluated in ways you don't expect, because and has higher priority than or. Example combining three operators:

kondisi-gabungan.yml
- name: Reboot server untuk maintenance
  hosts: all
  become: true
  tasks:
    - name: Reboot hanya di staging/production, kecuali sedang maintenance
      ansible.builtin.reboot:
      when: (env == "staging" or env == "production") and not maintenance_window

Long conditions can also be broken into multiple lines using YAML list syntax, where each list element is implicitly joined with and:

kondisi-multibaris.yml
- name: Skenario multi-kondisi
  hosts: all
  tasks:
    - name: Jalankan migrasi database
      ansible.builtin.command: bundle exec rails db:migrate
      when:
        - env == "production"
        - ansible_facts['os_family'] == "Debian"
        - deploy_needed | bool

Case Study: apt vs dnf Based on OS Family

One classic real-world scenario is installing the same package on servers with different OSes. The package manager on Debian/Ubuntu (apt) differs from RedHat/Rocky/Fedora (dnf). Without conditionals, you'd have to create separate playbooks. With when, one playbook handles both:

install-nginx.yml
- name: Install nginx sesuai family OS
  hosts: all
  become: true
  gather_facts: true
  tasks:
    - name: Install nginx via apt (Debian / Ubuntu)
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
      when: ansible_facts['os_family'] == "Debian"
 
    - name: Install nginx via dnf (RedHat / Rocky / Fedora)
      ansible.builtin.dnf:
        name: nginx
        state: present
      when: ansible_facts['os_family'] == "RedHat"

Tip

The ansible_facts['os_family'] fact is generated by the setup module when gather_facts is active. To see all available facts, run ansible <host> -m ansible.builtin.setup in your terminal. On Ubuntu the result is Debian, on Rocky Linux it's RedHat, so the checks above work automatically without hardcoding OS names.

Loops

Now imagine you have to install ten packages on every server. You could write ten identical apt tasks that only differ in the package name — but that means ten identical lines, prone to typos, and hard to maintain. It's like photocopying a recipe ten times when the essence is one: "install each package in this list". This is where loop takes over.

Modern Looping with loop

Since Ansible 2.5, the recommended looping syntax is the loop keyword. This syntax is simpler, easier to read, and the standard going forward. The default variable representing each loop element is item.

The most basic example — looping over a list of packages:

install-packages.yml
- name: Install package dasar
  hosts: all
  become: true
  tasks:
    - name: Install beberapa package sekaligus
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
        update_cache: true
      loop:
        - git
        - curl
        - htop
        - jq

The task above executes four times, each time with a different item value (git, then curl, and so on). In the execution output, each iteration appears as apt: name=git, apt: name=curl, and so on, so you can track which iteration succeeded or failed.

Lists can also contain dictionaries, so each iteration can carry several data points at once — for example, creating users in bulk:

create-users.yml
- name: Buat user untuk tim engineering
  hosts: all
  become: true
  tasks:
    - name: Buat user tiap developer
      ansible.builtin.user:
        name: "{{ item.name }}"
        shell: "{{ item.shell | default('/bin/bash') }}"
        groups: "{{ item.groups | default('devs') }}"
        state: present
      loop:
        - { name: budi, shell: /bin/bash, groups: "devs,sudo" }
        - { name: siti, groups: "devs" }
        - { name: agus, shell: /bin/zsh, groups: "ops" }

Notice how we leverage the default filter from episode 8: the shell and groups fields become optional, and siti automatically gets the default value. The combination of loop with item.field and filters is what keeps the playbook concise even with diverse data.

Looping over Dictionaries: item.key & item.value

Besides lists, you'll also often deal with dictionaries. To loop over a dictionary, Ansible recommends using the dict2items filter, which converts a dictionary into a list of {key, value}. After that, each item will have item.key and item.value attributes:

loop-dictionary.yml
- name: Tulis konfigurasi environment variable
  hosts: all
  become: true
  vars:
    app_config:
      LOG_LEVEL: info
      PORT: "8080"
      DATABASE_URL: postgres://app:pass@db.example.com/app
  tasks:
    - name: Tulis setiap key-value ke file konfigurasi
      ansible.builtin.lineinfile:
        path: /etc/app/app.conf
        line: "{{ item.key }}={{ item.value }}"
        create: true
      loop: "{{ app_config | dict2items }}"

Important

Don't forget the dict2items filter when looping over a dictionary with loop. Without that filter, the behavior is inconsistent and hard to predict. You can also customize the converted attribute names with the key_name and value_name parameters, e.g., dict2items(key_name='file', value_name='path').

Controlling Loops with loop_control

Sometimes we need more than just item. The loop_control keyword provides options to modify loop behavior:

OptionFunction
loop_varRenames the item variable (important for nested loops to avoid conflicts)
index_varProvides the iteration index number starting from 0
labelShows a concise label in output, useful when items contain large data structures
pauseInserts a pause (seconds) between iterations, useful to avoid API rate limits
extendedAdds metadata like ansible_loop.index, ansible_loop.first, and ansible_loop.last

Here's an example of usage that also shows how label makes output much more readable:

loop-control.yml
- name: Deploy konfigurasi untuk setiap service
  hosts: all
  become: true
  tasks:
    - name: Buat direktori konfigurasi per service
      ansible.builtin.file:
        path: "/etc/app/{{ item.name }}"
        state: directory
      loop:
        - { name: api,  port: 8080 }
        - { name: web,  port: 3000 }
        - { name: worker, port: 5000 }
      loop_control:
        label: "service {{ item.name }} (port {{ item.port }})"

Without label, the output prints the entire {name: api, port: 8080} dictionary for every iteration — noisy on screen. With label, each iteration appears as service api (port 8080), immediately understandable to humans.

The until Loop: Retry Until a Condition Is Met

There's one type of loop that's often misunderstood: until. This isn't a loop for processing data, but a retry loop — repeating the same task execution over and over until a condition is met or the attempt limit is reached. It's very useful for handling transient states, like waiting for a database service to be truly ready before an application tries to connect.

Real example: waiting for a PostgreSQL primary to accept connections before running a database migration:

wait-db.yml
- name: Tunggu PostgreSQL primary siap
  ansible.builtin.command: pg_isready -h db-primary -p 5432 -U app
  register: db_ready
  until: db_ready.rc == 0
  retries: 12
  delay: 5
  failed_when: db_ready.rc != 0

Warning

The until keyword must be combined with register and retries. Ansible evaluates the until expression against the registered task result, re-runs the task every delay seconds, and stops after retries attempts. If all attempts fail, the task is marked as FAILED with a message showing the last attempt's result. Another note: until is not supported on the include_tasks keyword.

The until pattern is very commonly used to wait for a port to open, wait for a container's healthy status, or wait for an application to finish restarting. This is the foundation of the resilience strategy we'll deepen in the next episode.

Migrating from with_items to loop

Before Ansible 2.5, loops were written with with_* syntax like with_items, with_dict, with_sequence, and with_fileglob. That syntax is now deprecated and will be removed from Ansible core, so modern playbooks must migrate to loop. The good news: this migration almost always just means renaming the keyword:

migrasi-with_items-ke-loop.yml
- name: Install beberapa package dasar
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  with_items:
  loop:
    - git
    - curl
    - htop

The migration rules to remember:

  • with_itemsloop — just swap the keyword, the list content stays the same.
  • with_dictloop + the dict2items or dictsort filter.
  • with_indexed_itemsloop + loop_control.index_var and the dict2items filter.
  • with_fileglob, with_sequence, and other with_* lookups → loop + lookup plugin, e.g., loop: "{{ query('ansible.builtin.fileglob', 'files/*.conf') }}".

Tip

Important difference: with_items flattens a list one level (e.g., lists inside lists are merged into one), while loop does not flatten automatically — a list item is treated as one intact list element. If your old playbook relies on this flattening behavior, add the flatten filter explicitly: loop: "{{ list_of_lists | flatten }}".

Common Pitfalls

1. Comparing strings as if they were booleans

This is the most classic trap. Consider:

pitfall-boolean.yml
- name: Contoh yang salah
  hosts: all
  vars:
    service_enabled: "no"
  tasks:
    - name: Restart service
      ansible.builtin.systemd_service:
        name: app
        state: restarted
      when: service_enabled

The value "no" is a string, not a boolean. In Jinja2's truthiness rules, any non-empty string is always true — even "no", "false", and "0". As a result, the task above always runs, even though you intended to disable it. The solution: make sure the variable is actually boolean, or convert it explicitly with the | bool filter:

pitfall-boolean-benar.yml
- name: Contoh yang benar
  hosts: all
  vars:
    service_enabled: "no"
  tasks:
    - name: Restart service hanya jika diaktifkan
      ansible.builtin.systemd_service:
        name: app
        state: restarted
      when: service_enabled | bool

With | bool, the string "no" evaluates to false and the task doesn't run. Always ask yourself: is the variable I'm testing actually boolean?

2. Misunderstanding the SKIPPED output

When a task is skipped because when evaluates to false, Ansible still continues the playbook execution — this is expected behavior. However, many beginners think the playbook stops or treat a skip as an error. On the contrary, SKIPPED is a normal and healthy status. It's FAILED that you should pay attention to, and its handling will be covered in the next episode.

3. item name conflicts in nested loops

If you loop inside a loop (nested loop), both use the same item variable — and the inner one overwrites the outer one. Use loop_control.loop_var to give different names:

nested-loop.yml
- name: Deploy multi-environment
  hosts: localhost
  vars:
    environments: [dev, staging]
    services: [api, web]
  tasks:
    - name: Kombinasi environment dan service
      ansible.builtin.debug:
        msg: "{{ env }}-{{ svc }}"
      loop: "{{ environments }}"
      loop_control:
        loop_var: env
      # perulangan kedua harus bersarang di dalam task dengan include_tasks,
      # atau kombinasikan kedua list terlebih dahulu dengan filter product()

In episode 11, we'll see how nested loops are handled correctly using include_tasks and loop_var.

4. Forgetting to add register for until

Without register, the until expression has no task result to evaluate and the playbook fails immediately with a confusing error message. Always register the result first.

Conclusion

In episode 9, we learned two control flow pillars in Ansible: conditioning with when, which lets one playbook adapt to each server's condition (including the apt vs dnf case study based on OS family), and looping with loop, which covers looping over lists, looping over dictionaries with item.key and item.value, controlling iterations with loop_control, the until retry loop, and migrating from the deprecated with_items syntax.

These capabilities transform your playbooks from a mere list of static instructions into programs that truly think: deciding whether a task needs to run, and running it repeatedly and efficiently. However, a smart playbook isn't necessarily resilient — what happens if one task fails halfway through? How do we keep the system consistent when errors occur?

In episode 10, we'll cover Error Handling & Resilience Strategies, from ignore_errors, failed_when, and changed_when, to the block, rescue, and always structure for building playbooks that don't easily give up in the middle of failure. Keep your enthusiasm up!