Ansible Conditionals and Loops: When, Loop, and Until in Production
Master Ansible conditionals (when) and loops (loop, with_items, until) with production patterns, debugging tips, and real incident lessons.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
Use when with Jinja2 expressions for conditional task execution; avoid bare variables (use var is defined instead).
Prefer loop over deprecated with_items for better control and consistency.
Use loop_control with label, index_var, and pause to manage output and rate-limit.
For polling, use until, retries, and delay; set register to capture result.
Nested loops require loop with subelements or product filter for combining lists.
Loop over dictionaries with dict2items filter to get key-value pairs.
Always use | default([]) when looping over potentially undefined variables.
Test conditionals with --check and --diff to avoid surprises.
Imagine you're a chef with a recipe book. Sometimes you only add salt if the dish is savory (conditional). Other times, you repeat the same step for each ingredient (loop). Ansible conditionals let you decide whether to run a task based on facts or variables, like checking if a service is installed before restarting it. Loops let you repeat tasks for multiple items, like creating 10 user accounts. Without them, you'd have to write a separate task for each user or manually check conditions, which is error-prone and hard to maintain. So conditionals and loops make your playbooks smarter and more concise.
Three years ago, I was responsible for a multi-datacenter deployment of a microservices platform. One night, a routine playbook run to update SSL certificates caused a 30-minute outage. The root cause? A missing conditional check: the playbook tried to restart nginx on all hosts, even those where nginx wasn't installed. The when clause I had written was when: nginx_running, but I had forgotten to register the nginx_running variable on all hosts. The result was an undefined variable error that halted the playbook mid-run, leaving half the hosts with expired certs. That incident taught me the hard way that conditionals and loops are not just syntax—they're safety nets.
Historically, Ansible conditionals evolved from simple only_if to the powerful when clause. Loops started with with_<type> items (like with_items, with_dict) and later introduced the unified loop keyword in Ansible 2.5. The until clause for retries was added to handle transient failures. Understanding these features is critical for writing idempotent, resilient playbooks.
This article covers production-grade usage of when with Jinja2 expressions, the loop keyword vs legacy with_items, loop_control for fine-tuning, until for polling, nested loops, and dictionary iteration. Each section includes real code examples and debugging tips.
1. The When Clause: Jinja2 Expressions and Gotchas
The when clause is the primary conditional in Ansible. It evaluates a Jinja2 expression and runs the task if the expression is true. Here's a production example:
``yaml - name: Restart nginx if config changed ansible.builtin.service: name: nginx state: restarted when: - nginx_config_result.changed - ansible_os_family == 'Debian' ``
Gotcha 1: Bare variables. Writing when: nginx_running is dangerous. If nginx_running is undefined, Ansible throws an error. Always use is defined or default:
``yaml when: nginx_running is defined and nginx_running.status == 'running' ``
Gotcha 2: Boolean coercion. In Jinja2, when: some_var works if some_var is a boolean. But if it's a string like "true", it's always true. Convert strings: when: some_var | bool.
Gotcha 3: Complex conditions. Use lists for AND conditions (as above) or and/or operators:
``yaml when: - (ansible_distribution == 'Ubuntu' and ansible_distribution_version == '20.04') or (ansible_distribution == 'Debian' and ansible_distribution_major_version == '10') ``
Production pattern: Use assert module to validate conditions early:
``yaml - name: Assert required variables ansible.builtin.assert: that: - required_var is defined - required_var | length > 0 ``
when: var. If var is undefined, the playbook fails. Always use when: var is defined and var.when: nginx_running caused a hard failure on hosts without nginx. Adding is defined and an ignore_errors: yes on the register task prevented the issue. We now enforce a lint rule in CI that flags bare variables in when.is defined or provide defaults in when clauses.2. The Loop Keyword: Modern Iteration
Since Ansible 2.5, the loop keyword is the recommended way to iterate. It accepts any list:
``yaml - name: Create users ansible.builtin.user: name: "{{ item }}" state: present loop: - alice - bob - charlie ``
With list of dicts:
``yaml - name: Add SSH keys ansible.builtin.authorized_key: user: "{{ item.name }}" key: "{{ item.key }}" loop: - { name: alice, key: "ssh-rsa AAA..." } - { name: bob, key: "ssh-rsa BBB..." } ``
Loop over registered results:
```yaml - name: Check if files exist ansible.builtin.stat: path: "{{ item }}" loop: - /etc/nginx/nginx.conf - /etc/nginx/sites-enabled/default register: file_stat
- name: Debug missing files
- ansible.builtin.debug:
- msg: "{{ item.item }} is missing"
- loop: "{{ file_stat.results }}"
- when: not item.stat.exists
- ```
Production gotcha: Always use | default([]) if the loop variable might be undefined:
``yaml loop: "{{ mylist | default([]) }}" ``
Performance: For large lists (thousands of items), consider using with_items (which is slightly faster) or split into batches. But for most cases, loop is fine.
loop: "{{ myvar | default([]) }}" to avoid errors.loop was 10% slower than with_items. We switched to with_items for that specific task and saw a 30-second improvement. But for most tasks, the difference is negligible.loop for consistency; default to empty list to handle undefined variables.3. Legacy with_items: When to Use (and Avoid)
The with_items syntax is deprecated but still widely used. It predates loop and has some differences:
``yaml - name: Install packages (legacy) ansible.builtin.package: name: "{{ item }}" state: present with_items: - nginx - postgresql ``
Key differences: - with_items automatically flattens lists: with_items: [ [a, b], c ] yields a, b, c. - with_items supports with_nested, with_dict, etc., but these are also deprecated. - loop is more consistent and works with filters like | flatten.
When to use with_items: - In legacy playbooks you don't want to refactor. - When you need automatic flattening (but loop: "{{ mylist | flatten }}" does the same).
Production recommendation: Convert to loop gradually. Use ansible-lint to flag with_items usage.
``yaml # Before with_items: "{{ mylist }}" # After loop: "{{ mylist | flatten }}" ``
Gotcha: with_items with when condition on each item requires item in the condition. Same as loop.
with_items still works in Ansible 9, but it may be removed in the future. Prefer loop for new code.with_items with flatten implicitly. After migrating to loop, we forgot to add | flatten and the loop broke. Always test after migration.loop over with_items; if you must use legacy, be aware of automatic flattening.4. Loop Control: Label, Index Var, and Pause
loop_control gives you fine-grained control over loop behavior.
Label: Customize the output to avoid clutter:
``yaml - name: Create users ansible.builtin.user: name: "{{ item }}" state: present loop: - alice - bob loop_control: label: "{{ item }}" ``
Index var: Track the iteration index:
``yaml - name: Add entries to config ansible.builtin.lineinfile: path: /etc/app/config line: "server{{ idx }}={{ item }}" loop: - 10.0.0.1 - 10.0.0.2 loop_control: index_var: idx ``
Pause: Add delay between iterations (useful for API rate limits):
``yaml - name: Call API for each user ansible.builtin.uri: url: "https://api.example.com/users/{{ item }}" loop: "{{ users }}" loop_control: pause: 5 # seconds between iterations ``
Production pattern: Combine with throttle for even more control:
``yaml - name: Throttled API calls ansible.builtin.uri: url: "https://api.example.com/items/{{ item }}" loop: "{{ items }}" loop_control: pause: 1 throttle: 10 ``
Gotcha: pause is not a replacement for idempotency. If the task fails, the loop continues. Use until for retries.
pause for simple delays and throttle for concurrency limits. They can be combined.loop_control: pause: 2 resolved it, but we also added until to retry on 429 responses.loop_control to manage output, indices, and rate limits in loops.5. Until, Retries, and Delay: Polling for Success
The until clause retries a task until a condition is met. Combined with retries and delay, it's ideal for polling:
``yaml - name: Wait for service to be healthy ansible.builtin.uri: url: "http://localhost:8080/health" register: result until: result.status == 200 retries: 10 delay: 5 ``
Production example: Wait for database migration:
``yaml - name: Check migration status ansible.builtin.shell: cmd: "psql -c \"SELECT count(*) FROM migrations WHERE status='done';\" -t -A" register: migration_result until: migration_result.stdout | int > 0 retries: 12 delay: 10 ``
Important: The task must register a variable. The until condition uses that variable. If the task fails (non-zero return code), the retry still happens unless ignore_errors: yes is set.
Gotcha: The until condition is evaluated after the task runs. If the task always fails (e.g., connection refused), retries will be exhausted. Use failed_when to handle specific errors.
``yaml - name: Poll with custom failure ansible.builtin.uri: url: "http://localhost:8080/health" register: result until: result.status == 200 retries: 5 delay: 2 failed_when: result.status not in [200, 503] # 503 is retriable ``
until still retries. Use ignore_errors: yes if the task might fail before the condition is met.failed_when, the task failed immediately. Adding failed_when: result.status not in [200, 503] allowed retries on 503.until with retries and delay for polling; combine with failed_when for robust error handling.6. Nested Loops: Combining Multiple Lists
Ansible supports nested loops via the product filter or subelements lookup.
Using product for cartesian product:
``yaml - name: Create all combinations ansible.builtin.debug: msg: "{{ item.0 }} - {{ item.1 }}" loop: "{{ ['a','b'] | product(['1','2']) | list }}" ``
Using subelements for structured data:
``yaml - name: Add users to groups ansible.builtin.user: name: "{{ item.0 }}" groups: "{{ item.1 }}" append: yes loop: "{{ users | subelements('groups') }}" vars: users: - name: alice groups: - wheel - docker - name: bob groups: - docker ``
Production gotcha: subelements requires the subelement key to exist in every item. Use default([]) if a user might have no groups:
``yaml loop: "{{ users | subelements('groups', skip_missing=True) }}" ``
Parallel iteration over two lists:
``yaml - name: Iterate over two lists in parallel ansible.builtin.debug: msg: "{{ item.0 }} -> {{ item.1 }}" loop: "{{ list1 | zip(list2) | list }}" ``
subelements('key', skip_missing=True) to skip items where the subelement key is missing.subelements to assign SSH keys to users from a list of dicts. One user entry was missing the groups key, causing an error. Adding skip_missing=True fixed it.subelements; for cartesian product, use product; for parallel iteration, use zip.7. Looping Over Dictionaries
To iterate over a dictionary, convert it to a list of key-value pairs using dict2items:
``yaml - name: Set sysctl values ansible.posix.sysctl: name: "{{ item.key }}" value: "{{ item.value }}" state: present loop: "{{ sysctl_settings | dict2items }}" vars: sysctl_settings: net.ipv4.ip_forward: 1 vm.swappiness: 10 ``
Without dict2items (legacy):
``yaml loop: "{{ sysctl_settings | dict2items }}" ``
Loop over keys only:
``yaml loop: "{{ ``mydict.keys() | list }}"
Loop over values only:
``yaml loop: "{{ ``mydict.values() | list }}"
Production pattern: Use with when to filter:
``yaml - name: Enable services from dict ansible.builtin.service: name: "{{ item.key }}" enabled: yes loop: "{{ services | dict2items }}" when: item.value.enabled vars: services: nginx: enabled: true postgresql: enabled: false ``
dict2items converts a dict to a list of dicts with key and value keys. Available since Ansible 2.5.dict2items to loop over dictionaries; access item.key and item.value.8. Combining Conditionals and Loops
You can use when inside a loop to filter items:
``yaml - name: Install packages conditionally ansible.builtin.package: name: "{{ item }}" state: present loop: "{{ packages }}" when: item not in installed_packages ``
Using selectattr filter:
``yaml - name: Start only enabled services ansible.builtin.service: name: "{{ item.name }}" state: started loop: "{{ services | selectattr('enabled', 'equalto', true) | list }}" ``
Production pattern: Loop over hosts in a group with condition:
``yaml - name: Deploy config to web servers ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf loop: "{{ groups['web'] }}" when: hostvars[item].ansible_os_family == 'Debian' ``
Gotcha: The when condition is evaluated for each item. If the condition is expensive (e.g., calling a module), consider filtering the list beforehand using selectattr or rejectattr.
when on each item is fine for small lists. For thousands of items, filter the list with Jinja2 filters to reduce iterations.when condition that checked a fact. The playbook was slow. We switched to filtering the group using groups['web'] | selectattr(...) and it sped up 10x.when with loops for item-level filtering; use Jinja2 filters for performance.9. Debugging Conditionals and Loops
Use debug module to inspect variables:
``yaml - name: Debug loop items ansible.builtin.debug: var: item loop: "{{ mylist | default([]) }}" ``
Print registered results:
``yaml - name: Print the whole result ansible.builtin.debug: var: result ``
Use --step to interactively confirm each task:
``bash ansible-playbook playbook.yml --step ``
Use --start-at-task to skip ahead:
``bash ansible-playbook playbook.yml --start-at-task='Start nginx' ``
Check syntax with --syntax-check:
``bash ansible-playbook playbook.yml --syntax-check ``
Production debugging: Use -vvv for verbose output:
``bash ansible-playbook playbook.yml -vvv | tee debug.log ``
Common error: The conditional check '...' failed. The error was: error while evaluating conditional
Fix: Ensure the variable is defined. Use | or default()is defined.
-vvv shows evaluated conditionals and loop items. Use it to trace logic.debug task before the failing task to print the relevant variables. This avoids guesswork.debug, --step, and -vvv to troubleshoot conditionals and loops.10. Advanced Patterns: Loop with Include and Import
You can loop over include_tasks or import_tasks to dynamically include roles or tasks:
``yaml - name: Include tasks for each service ansible.builtin.include_tasks: file: deploy_service.yml loop: "{{ services }}" loop_control: loop_var: service ``
Note: include_tasks is dynamic (evaluated at runtime), while import_tasks is static (pre-processed). For loops, use include_tasks.
Using include_role with loop:
``yaml - name: Apply role to each database ansible.builtin.include_role: name: setup_db loop: "{{ databases }}" loop_control: loop_var: db vars: db_name: "{{ db.name }}" db_user: "{{ db.user }}" ``
Production pattern: Loop over items and include a task file that handles each item:
``yaml # main.yml - name: Process each host ansible.builtin.include_tasks: file: process_host.yml loop: "{{ groups['all'] }}" loop_control: loop_var: current_host ``
Gotcha: When using include_tasks in a loop, variables from the outer scope are available, but loop_var is recommended to avoid variable collision.
loop_var with include_tasks to avoid overwriting the default item variable.include_tasks with a loop to deploy microservices. Without loop_var, the inner tasks overwrote the outer item, causing hard-to-debug failures.include_tasks with loop_var to dynamically include tasks in a loop.11. Performance Considerations for Large Loops
Loops over thousands of items can be slow. Here are optimization tips:
1. Use throttle to control concurrency:
``yaml - name: Update many hosts ansible.builtin.shell: cmd: "update_{{ item }}" loop: "{{ large_list }}" throttle: 20 # run 20 at a time ``
2. Use async with poll for long-running tasks:
```yaml - name: Run long tasks in parallel ansible.builtin.shell: cmd: "process_{{ item }}" loop: "{{ items }}" async: 300 poll: 0 register: async_results
- name: Wait for all tasks
- ansible.builtin.async_status:
- jid: "{{ item.ansible_job_id }}"
- loop: "{{ async_results.results }}"
- register: async_poll
- until: async_poll.finished
- retries: 30
- ```
3. Use with_items for very large lists (slightly faster):
``yaml - name: Legacy loop for performance ansible.builtin.shell: cmd: "echo {{ item }}" with_items: "{{ huge_list }}" ``
4. Minimize when conditions inside loops:
Filter the list beforehand using Jinja2 filters like selectattr or rejectattr.
async with loops, ensure you capture all job IDs and poll them. Missing a poll can leave orphan processes.throttle: 50 and async, we reduced it to 15 minutes. The key was balancing concurrency with system load.throttle, async, and pre-filtering to reduce runtime.12. Testing and Validating Conditionals and Loops
Use ansible-playbook --check to dry-run:
``bash ansible-playbook playbook.yml --check --diff ``
Use assert module to test conditions:
``yaml - name: Validate loop list ansible.builtin.assert: that: - mylist | length > 0 - mylist | type_debug == 'list' ``
Use molecule for role testing:
``yaml # molecule/default/verify.yml - name: Verify loop results ansible.builtin.assert: that: - result.results | length == 3 - result.results | selectattr('changed', 'equalto', true) | list | length == 0 ``
Use ansible-lint to catch common issues:
``bash ansible-lint playbook.yml ``
Production CI pipeline:
``yaml # .github/workflows/ansible-lint.yml - name: Lint Ansible run: ansible-lint playbooks/ ``
Gotcha: --check does not guarantee idempotency. Some modules (e.g., shell) always report changes. Test with --diff to see what would change.
ansible-lint catches bare variables in when and recommends loop over with_items. Enable rules fqcn-builtins and no-bare-vars.ansible-lint to CI after a bare variable in when caused a production outage. Now it's a mandatory gate before merge.--check, ansible-lint, and assertions in CI.Undefined Variable in when Clause Causes Playbook Abort
The conditional check 'nginx_running' failed. The error was: error while evaluating conditional (nginx_running): 'nginx_running' is undefined on some hosts.nginx_running would be defined on all hosts because the role was applied to all web servers.nginx_running variable was only registered on hosts where nginx was actually installed. On hosts without nginx, the register step was skipped, leaving the variable undefined.when: nginx_running is defined and nginx_running.status == 'running' and ensure the register task runs unconditionally with ignore_errors: yes or by setting a default.- Always check for variable existence in conditionals using
is definedor provide defaults with|.default() - Never assume a variable exists from a conditional task.
The conditional check 'foo' failed. The error was: error while evaluating conditional (foo): 'foo' is undefinedis defined check: when: foo is defined and foo. Or use | default(False).loop: "{{ mylist | default([]) }}" to default to empty list.until loop runs forever or hits retry limitdebug to print the registered variable value. Ensure the condition evaluates to true when done.loop: "{{ list1 | zip(list2) | list }}" for parallel iteration, or subelements for nested structures.ansible host -m debug -a 'var=nginx_running'ansible host -m setup | grep -i nginxwhen: nginx_running is defined and nginx_running.status == 'running'Key takeaways
is defined or | default() in when clauses to avoid undefined variable errors.loop over deprecated with_items; use | flatten if you need flattening.loop_control with label, index_var, and pause to manage output and rate limits.until, retries, and delay; always register the result.dict2items filter.product, zip, or subelements for nested loops.throttle, async, and pre-filtering.--check, ansible-lint, and assertions in CI.Common mistakes to avoid
6 patternsUsing bare variable in `when` clause
The conditional check 'var' failed. The error was: error while evaluating conditional (var): 'var' is undefinedwhen: var is defined and var or when: var | default(false) | boolForgetting to default empty list in loop
'list' is undefined error when looping over a variable that may not be definedloop: "{{ mylist | default([]) }}"Using `with_items` when `loop` is sufficient
[DEPRECATION WARNING]: with_items is deprecatedwith_items with loop and add | flatten if neededNot using `loop_var` with `include_tasks` in a loop
itemloop_control: loop_var: my_item and use my_item in included tasksUsing `until` without `register`
until condition always fails because variable is undefinedregister: result and use result in until conditionUsing `product` filter without `list`
'generator' object has no attribute '__iter__'| list after product: loop: "{{ list1 | product(list2) | list }}"Interview Questions on This Topic
What is the difference between `loop` and `with_items` in Ansible?
loop is the modern keyword introduced in Ansible 2.5. It accepts a list and does not automatically flatten nested lists. with_items is deprecated and automatically flattens lists. loop is preferred for consistency and future compatibility. To flatten with loop, use the | flatten filter.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Ansible. Mark it forged?
9 min read · try the examples if you haven't