Skip to main content
RunBook Academy

AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters

Jinja tests and conditionals that fail safe

Intermediate⏱ ~20 minansible-docansible-playbook

What you'll learn

  • Use the tests that answer the questions conditionals actually ask
  • Explain why when: myvar and when: "{{ myvar }}" are not the same, and what 2.21 does about each
  • Distinguish is defined from is truthy and choose deliberately
  • Decide when a conditional should skip and when skipping is the worse outcome

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.

A test is a plugin that answers a yes/no question about a value. You write them after is or is not, and you pass their names as strings to select, reject, selectattr and rejectattr.

Read-only / Safehow many there are
$ ansible-doc -t test -l | wc -l
82

You need about six of them. What you also need — and this is the part that causes incidents — is a clear model of what a conditional does when the data it is testing is not the shape you assumed.

The two spellings of when:, and what 2.21 does

Start here, because it is the change most likely to break an existing repository on upgrade.

Read-only / Safetests2.yml
vars:
real_bool: false
enabled: 'false'        # a STRING, from an environment variable or extra-var

tasks:
- name: bare name with a real boolean
  ansible.builtin.debug:
    msg: "ran"
  when: real_bool

- name: templated conditional
  ansible.builtin.debug:
    msg: "ran"
  when: "{{ real_bool }}"
Read-only / Safethe templated form is deprecated
$ ansible-playbook -i localhost, tests2.yml
TASK [bare name with a real boolean] *******************************************
skipping: [localhost]

TASK [templated conditional] ***************************************************
[DEPRECATION WARNING]: Conditionals should not be surrounded by templating delimiters such as {{ }} or {% %}. This feature will be removed from ansible-core version 2.23.
Origin: /home/opsuser/project/tests2.yml:15:13

13       ansible.builtin.debug:
14         msg: "ran"
15       when: "{{ real_bool }}"
             ^ column 13

skipping: [localhost]

when: is already an expression context. The braces are redundant, they have been discouraged for years, and 2.21 attaches a dated removal notice: ansible-core 2.23. A repository with hundreds of when: "{{ ... }}" conditionals has a deadline, and the warning is the notice.

Now the more interesting one. enabled is the string 'false', which is what you get from an environment variable, a -e extra-var, or an unquoted YAML value that someone quoted:

Read-only / Safea non-boolean conditional is now an error
$ ansible-playbook -i localhost, tests.yml
TASK [when on a string] ********************************************************
[ERROR]: Task failed: A 'when' expression failed: Conditional result (True) was derived from value of type 'str' at '/home/opsuser/project/tests.yml:5:14'. Conditionals must have a boolean result.

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

fatal: [localhost]: FAILED! => {"msg": "Task failed: A 'when' expression failed: Conditional result (True) was derived from value of type 'str' at '/home/opsuser/project/tests.yml:5:14'. Conditionals must have a boolean result."}

Read the message closely. It says the result was True, derived from a str. That is the bug, stated plainly: the string 'false' is non-empty, therefore truthy, therefore the task guarded by when: enabled would have run.

defined, truthy and the difference

Read-only / Safethree values, three questions
$ ansible-playbook -i localhost, tests.yml
TASK [string false is truthy] **************************************************
ok: [localhost] => {
  "msg": "bool=False truthy=True truthy_convert=False"
}

TASK [defined vs truthy] *******************************************************
ok: [localhost] => {
  "msg": "empty defined=True truthy=False zero truthy=False"
}

Three distinct questions, and confusing them is where conditionals go wrong:

  • is defined — does this name exist? An empty string is defined. A variable set to false is defined. This asks about presence.
  • is truthy — is this value non-empty and non-zero, by Python rules? '' and 0 are falsy; the string 'false' is truthy, because it is a non-empty string.
  • is truthy(convert_bool=True) — apply Ansible’s boolean-string conversion first, so 'false' becomes False. This is the test form of | bool.

The pattern that handles an optional flag correctly:

Read-only / Safeoptional flag, no assumptions
- name: Enable the feature
ansible.builtin.template:
  src: feature.conf.j2
  dest: /etc/app/feature.conf
when: app_feature_enabled | default(false) | bool

default(false) handles absence, | bool handles a string, and the result is a genuine boolean. Three tokens, and the task is correct for every way the variable can arrive.

match, search and version

Read-only / Safematch anchors, search does not
$ ansible-playbook -i localhost, tests.yml
TASK [match and search] ********************************************************
ok: [localhost] => {
  "msg": "match=True search=True match-mid=False"
}

TASK [version test] ************************************************************
ok: [localhost] => {
  "msg": true
}

Against the string '2.21.3':

  • is match('2\.21')true. match anchors at the start.
  • is search('21')true. search finds it anywhere.
  • is match('21')false. The string does not start with 21.

That last one is the whole distinction and it accounts for most “my regex conditional does nothing” reports.

is version('2.20', '>=') does a proper version comparison rather than a string one, which matters because '2.9' sorts after '2.21' as a string and before it as a version. Use it for every ansible_distribution_version and package-version comparison.

Read-only / Safeversion comparison in a real conditional
- name: Use the modern config layout
ansible.builtin.template:
  src: modern.conf.j2
  dest: /etc/app/app.conf
when: ansible_distribution_version is version('12', '>=')

subset and superset

Read-only / Safeset containment
$ ansible-playbook -i localhost, tests.yml
TASK [subset] ******************************************************************
ok: [localhost] => {
  "msg": "True / False"
}

['web','db'] is subset(['web','db','cache']) is true; the reverse is false. Useful for preconditions — does this host belong to every group this play requires — and readable in a way that a chain of in checks is not.

Skipping versus exploding

A conditional referring to an undefined variable does not skip by default; it fails. That is usually right. But the idiom that makes it skip is common and worth understanding, because it has a sharp edge:

Read-only / Safeshort-circuit to a skip
$ ansible-playbook -i localhost, tests2.yml
TASK [undefined skips quietly] *************************************************
skipping: [localhost]
when: maybe_defined is defined and maybe_defined

Jinja short-circuits and, so the second operand is never evaluated when the first is false, and the task skips instead of erroring.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A variable arrives from the command line as -e "enabled=false" and a task has when: enabled. What happens on ansible-core 2.21, and what happened before the check existed?

  2. Q2. Against the string '2.21.3', which of these is false?

  3. Q3. Which statements about when: "{{ myvar }}" are accurate? Select all that apply.

  4. Q4. when: thing is defined and thing is the safe default for any conditional that might reference a missing variable.

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