AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters
Jinja tests and conditionals that fail safe
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
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.
$ ansible-doc -t test -l | wc -l82You 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.
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 }}"$ ansible-playbook -i localhost, tests2.ymlTASK [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:
$ ansible-playbook -i localhost, tests.ymlTASK [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
$ ansible-playbook -i localhost, tests.ymlTASK [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 tofalseis defined. This asks about presence.is truthy— is this value non-empty and non-zero, by Python rules?''and0are 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'becomesFalse. This is the test form of| bool.
The pattern that handles an optional flag correctly:
- name: Enable the feature
ansible.builtin.template:
src: feature.conf.j2
dest: /etc/app/feature.conf
when: app_feature_enabled | default(false) | booldefault(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
$ ansible-playbook -i localhost, tests.ymlTASK [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.matchanchors at the start.is search('21')— true.searchfinds it anywhere.is match('21')— false. The string does not start with21.
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.
- 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
$ ansible-playbook -i localhost, tests.ymlTASK [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:
$ ansible-playbook -i localhost, tests2.ymlTASK [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
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?
Q2. Against the string '2.21.3', which of these is false?
Q3. Which statements about when: "{{ myvar }}" are accurate? Select all that apply.
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.