Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsConditionals

when expressions that hold up

Intermediate⏱ ~24 minansible-playbook

What you'll learn

  • Write when expressions without the templating braces and explain why
  • Predict what ansible-core 2.21 does with a non-boolean conditional result
  • Apply bool and int where a comparison needs a type it does not have
  • Distinguish undefined, empty and false when writing a guard

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

when decides whether a task runs on a host. It is the smallest piece of control flow Ansible has and it is responsible for a disproportionate share of the incidents in this course, because the characteristic failure of a when is not an error. It is a task that quietly does not run.

ansible-core 2.21 changes a large part of that, and changes it for the better. This lesson covers the mechanics, then the 2.21 behaviour, then the traps that are still traps.

when is raw Jinja

The value of when is a Jinja expression, evaluated directly. It does not go through the templating braces:

Read-only / Safethe syntax, and the thing everyone does once
# Correct.
- name: Install with apt
ansible.builtin.package:
  name: chrony
when: ansible_facts.os_family == 'Debian'

# Wrong. The braces are already implied.
- name: Install with apt
ansible.builtin.package:
  name: chrony
when: "{{ ansible_facts.os_family == 'Debian' }}"

The second form has historically been the source of a specific bug: the braces render the expression to the string "True" or "False", and both of those strings are truthy, so the task runs unconditionally. That is precisely the failure mode 2.21 now refuses to have.

A list under when is ANDed:

when:
  - ansible_facts.os_family == 'Debian'
  - ansible_facts.distribution_major_version | int >= 12

Both must be true. There is no list form for OR — write or in the expression, or restructure the inventory, which lesson 7 argues is usually the real answer.

What 2.21 does with a non-boolean

Here is a play designed to hit the classic trap. s_false is the string "false", which in every version of Jinja is truthy because it is a non-empty string.

Read-only / Safethe truthiness of things that look false
$ ansible-playbook truth.yml
TASK [Report how each value evaluates in a raw Jinja conditional] **************
ok: [localhost] => {
  "msg": "s_false=TRUE s_no=TRUE s_zero=TRUE s_empty=FALSE b_false=FALSE n_zero=FALSE"
}

The string "false" is truthy. The string "no" is truthy. The string "0" is truthy. Only the empty string, the real boolean false and the integer 0 are falsy.

Now the same value used as a conditional:

Read-only / Safe2.21 refuses rather than guessing
$ ansible-playbook truth.yml
TASK [This task runs even though s_false looks false] ***************************
[ERROR]: Task failed: A 'when' expression failed: Conditional result (True)
was derived from value of type 'str' at 'truth.yml:5:14'. Conditionals must
have a boolean result.

Origin: truth.yml:25:13

23       ansible.builtin.debug:
24         msg: "I ran. when was the string false."
25       when: s_false
             ^ column 13

Broken conditionals can be temporarily allowed with the
'ALLOW_BROKEN_CONDITIONALS' configuration option.

fatal: [localhost]: FAILED!

Read the error carefully, because it is unusually good. It reports:

  • the result it derived (True),
  • the type it came from (str),
  • the origin of the valuetruth.yml:5:14, where s_false was defined,
  • the origin of the conditional — line 25, where it was used.

Two file positions, one for the definition and one for the use. That is exactly the pair you need to fix this class of bug and exactly the pair older versions did not give you.

The escape hatch, and its expiry

Read-only / Safethe setting, its default and its removal date
$ ansible-config list | grep -A 17 '^ALLOW_BROKEN_CONDITIONALS'
ALLOW_BROKEN_CONDITIONALS:
default: false
description:
- When enabled, this option allows conditionals with non-boolean results to be used.
- A deprecation warning will be emitted in these cases.
- By default, non-boolean conditionals result in an error.
- Such results often indicate unintentional use of templates where they are not
  supported, resulting in a conditional that is always true.
- When this option is enabled, conditional expressions which are a literal 'None'
  or empty string will evaluate as true for backwards compatibility.
env:
- name: ANSIBLE_ALLOW_BROKEN_CONDITIONALS
ini:
- key: allow_broken_conditionals
  section: defaults
type: boolean
version_added: '2.19'

Turning it on downgrades the error to a deprecation warning that names the removal version:

Read-only / Safethe same play with the escape hatch on
$ ANSIBLE_ALLOW_BROKEN_CONDITIONALS=True ansible-playbook truth.yml
TASK [This task runs even though s_false looks false] ***************************
[DEPRECATION WARNING]: Conditional result (True) was derived from value of
type 'str' at 'truth.yml:5:14'. Conditionals must have a boolean result.
This feature will be removed from ansible-core version 2.23.

Broken conditionals are currently allowed because the
'ALLOW_BROKEN_CONDITIONALS' configuration option is enabled.

ok: [localhost] => {
  "msg": "I ran. when was the string false."
}

TASK [With the bool filter is skipped] *****************************************
skipping: [localhost]

Treat this as a migration tool with a deadline, not a setting. Turn it on to get an upgrade over the line, collect the deprecation warnings into a list of files to fix, fix them, turn it off. It stops working in 2.23.

The traps that are still traps

The 2.21 change catches conditionals that evaluate to the wrong type. It does not catch conditionals that evaluate to a boolean by a wrong route.

String comparison where you meant numeric

Read-only / Safea comparison that is a valid boolean and a wrong answer
$ ansible-playbook truth.yml
TASK [String comparison against an integer-looking fact] ***********************
ok: [localhost] => {
  "msg": "compare: False but int-compared: True"
}

'26' > '9' is false, because string comparison is character by character and '2' sorts before '9'. '26' | int > '9' | int is true.

This matters because ansible_facts.distribution_major_version is a string. A conditional written as:

when: ansible_facts.distribution_major_version > '9'

is a perfectly valid boolean expression that returns false on Debian 12, RHEL 10 and Ubuntu 26 — every host you actually wanted it to match. Nothing errors. The task skips. The recap is green.

Always cast:

when: ansible_facts.distribution_major_version | int >= 12

is defined on something that is defined and empty

Covered in Part XIV lesson 3 and it belongs here too, because it is a conditional problem: ansible_facts.default_ipv4 is {} on a host with no default route. is defined is true. The next line fails.

Test the value you are about to read.

| bool on a string that is not a boolean word

| bool maps a specific set of words. 'true', 'yes', 'on', '1' become true; 'false', 'no', 'off', '0' become false. Anything else — 'enabled', 'TRUE ' with a trailing space, 'y' — is not guaranteed and does not error in a way you will notice.

If a value’s source is a text file, INI fact, or environment variable, normalise at the boundary rather than at each use:

- name: Normalise once, near the source
  ansible.builtin.set_fact:
    app_maintenance_mode: >-
      {{ (ansible_facts.ansible_local.app.maintenance | default('false'))
         | trim | lower | bool }}

Assembling a conditional that holds up

Read-only / Safethe same requirement, written defensively
- name: Apply the modern configuration on supported releases
ansible.builtin.template:
  src: app.conf.j2
  dest: /etc/app/app.conf
  mode: '0644'
when:
  - ansible_facts.os_family == 'Debian'
  - ansible_facts.distribution_major_version | int >= 12

- name: Record hosts excluded by the release check
ansible.builtin.debug:
  msg: >-
    SKIPPED {{ inventory_hostname }}:
    {{ ansible_facts.distribution }}
    {{ ansible_facts.distribution_major_version }} is below the
    minimum supported release
when:
  - ansible_facts.os_family == 'Debian'
  - ansible_facts.distribution_major_version | int < 12

The second task is not decoration. On a fleet where five hosts are on an old release, that debug is the only thing that will tell you so — and it is the difference between “the run was green” and “the run was green and here are the five hosts it deliberately did not touch”.

Knowledge check

Knowledge check · 4 questions

  1. Q1. On ansible-core 2.21, what happens to a task with when: some_var where some_var holds the string "false"?

  2. Q2. A conditional written as when: ansible_facts.distribution_major_version > '9' matches no hosts on a fleet of Debian 12 and Ubuntu 26 machines. Why?

  3. Q3. Which of these produce a conditional that is a valid boolean but potentially the wrong answer? Select all that apply.

  4. Q4. A when: expression is written as raw Jinja and should not be wrapped in templating braces.

Passing score: 75%. Answers are checked in this browser.