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.

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.
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.
when Keyword: The Basis of Every Task DecisionThe 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:
- 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:
- 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'] }}"and, or, and notIn 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:
| Operator | Function | Example |
|---|---|---|
and | All conditions must be true | when: ansible_facts['os_family'] == "Debian" and env == "production" |
or | One true condition is enough | when: env == "staging" or env == "production" |
not | Reverses the evaluation result | when: not maintenance_window |
() | Groups logic priority | when: (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:
- 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_windowLong conditions can also be broken into multiple lines using YAML list syntax, where each list element is implicitly joined with and:
- 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 | boolapt vs dnf Based on OS FamilyOne 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:
- 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.
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.
loopSince 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:
- 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
- jqThe 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:
- 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.
item.key & item.valueBesides 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:
- 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').
loop_controlSometimes we need more than just item. The loop_control keyword provides options to modify loop behavior:
| Option | Function |
|---|---|
loop_var | Renames the item variable (important for nested loops to avoid conflicts) |
index_var | Provides the iteration index number starting from 0 |
label | Shows a concise label in output, useful when items contain large data structures |
pause | Inserts a pause (seconds) between iterations, useful to avoid API rate limits |
extended | Adds 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:
- 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.
until Loop: Retry Until a Condition Is MetThere'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:
- 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 != 0Warning
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.
with_items to loopBefore 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:
- name: Install beberapa package dasar
ansible.builtin.apt:
name: "{{ item }}"
state: present
with_items:
loop:
- git
- curl
- htopThe migration rules to remember:
with_items → loop — just swap the keyword, the list content stays the same.with_dict → loop + the dict2items or dictsort filter.with_indexed_items → loop + 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 }}".
1. Comparing strings as if they were booleans
This is the most classic trap. Consider:
- name: Contoh yang salah
hosts: all
vars:
service_enabled: "no"
tasks:
- name: Restart service
ansible.builtin.systemd_service:
name: app
state: restarted
when: service_enabledThe 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:
- 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 | boolWith | 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:
- 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.
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!