Conditionals and when
when: decides whether a task runs on a given host, and it is the one place in Ansible where you write raw Jinja2 with no {{ }} braces — the value is already an expression. It is evaluated per host against that host's facts and variables, so the same task can run on web1 and skip on db1 in a single play, with no extra structure.
The traps are all about types. Ansible reads YAML, and a value that looks false in the file is often a non-empty string that Jinja2 treats as true. Master the truthiness rules and definedness guards, and when is the workhorse you reach for on nearly every nontrivial task.
Raw Jinja, No Braces
A when value is already a Jinja2 expression, so you write the bare condition: when: ansible_distribution == "Ubuntu", not when: "{{ ansible_distribution == 'Ubuntu' }}". Wrapping it in braces works by accident in the common case and is wrong on principle — ansible-lint flags it, because when evaluates Jinja itself and the braces double-template a value that was never a string. Drop the braces and the expression reads as exactly what it is.
- name: Install the Ubuntu-only telemetry agent ansible.builtin.apt: name: larkspur-agent state: present when: ansible_distribution == "Ubuntu"
On a play that targets a mixed fleet, this single line splits behavior cleanly: the Ubuntu web nodes install the agent, the Debian database nodes skip the task and report skipping, and the play moves on. The condition is re-evaluated for every host, against that host's own gathered facts.
The String-versus-Bool Trap
This is where the time goes. A when: my_flag where my_flag holds the YAML string "false" is truthy, and the task runs on every host, because a non-empty string is true in Jinja2 regardless of what the letters spell. Only a real boolean false, an empty string, 0, an empty list, or an undefined-then-defaulted-empty value is falsey. The word "false" in quotes is just five characters, and five characters is true.
The fix is to store flags as real booleans — install_extras: false with no quotes — so the YAML parser hands Jinja2 an actual False and the off switch turns the task off. If a value can arrive as a string from --extra-vars or an environment-derived var, coerce it explicitly with | bool rather than trusting the type.
when actually evaluates — three values that look alike"false"falsefalse reaches Jinja2 as real False, so the task skips — the off switch that works.is definedDefinedness Tests
A when that reads a variable which does not exist on this host errors at run time rather than skipping — Ansible raises an undefined-variable failure and the host drops out. Guard optional variables with is defined / is not defined, or supply a fallback with | default(...), so the expression always has something to evaluate. The order matters: write when: db_port is defined and db_port > 5432, and the short-circuit stops before the comparison ever touches an undefined name.
Combining Conditions
A list under when: is an implicit and — every item must hold for the task to run — which reads cleanly for the common all-must-be-true case. When you need real mixed logic, use explicit and / or with parentheses in a single expression. The danger is mixing the two carelessly: a list of conditions that you mentally read as "A and (B or C)" is actually "(A and B) and C", and the task quietly runs on the wrong hosts.
- name: Open the admin port on production web nodes only ansible.builtin.command: /usr/local/bin/open-admin-port when: - inventory_hostname in groups["web"] - env == "prod"
Reserve the explicit operators for the cases the list cannot express. The moment your condition genuinely needs an or, write the whole thing on one line with parentheses you can read, rather than trying to bend the list syntax into doing something it does not do.
when on a Block and on Registered Results
when on a block gates every task inside it at once — one condition, applied to the whole group, instead of repeated on each task. And when: result.rc == 0 reads a variable captured by an earlier register:, which is the everyday way one task's outcome decides the next: run a check, register what it reported, and gate the follow-up on it. A skipped registering task still produces a result, but its keys differ, so test result.skipped is not defined before reading result.rc if the earlier task could itself have been skipped.
- Setting
install_extras: "false"ingroup_varsand gating a task withwhen: install_extras— the quoted string is truthy, the task runs on every host, and the "off switch" does nothing at all. - Wrapping the condition in braces,
when: "{{ ansible_os_family == 'Debian' }}"— it happens to work today but is a lint error and breaks the moment the expression returns a non-string and the braces re-template it. - Referencing
when: db_port > 5432on a host wheredb_portwas never set — the play fails with an undefined-variable error instead of skipping; guard it withdb_port is defined and ...first. - Writing
when: result.changed or result.failedand assumingresulthas those keys even when the registering task was skipped — a skipped task registers a result with different keys, so testresult.skipped is not definedbefore reading them. - Stacking three conditions in a list while meaning "A and (B or C)" — the list is pure
and, so the task runs on a narrower set than intended and theorbranch silently never fires.
- Write conditions as bare expressions with no
{{ }}, and let ansible-lint catch the braced ones that slip through review. - Store real booleans —
true/falseunquoted — in any var meant as a flag, and never rely on"false"the string to disable anything. - Guard every optional variable with
is definedor| default(...)so awhennever reads an undefined value and aborts the run. - Use a YAML list under
when:for the common all-must-hold case, and reserve explicitand/orwith parentheses for genuinely mixed logic. - Coerce string-typed inputs from
--extra-varswith| boolbefore testing them, so a value passed on the command line behaves like the boolean you intended.
if / unless and onlyif exec guards
Chef only_if / not_if resource guards
Terraform count = condition ? 1 : 0, provisioning-side, with no per-host evaluation
Knowledge Check
Why does a when: value take raw Jinja2 with no {{ }} braces?
- The value is already evaluated as a Jinja2 expression, so braces would double-template it and ansible-lint flags them
- Ansible strips the braces from every keyword automatically before templating, so they are merely redundant
- YAML forbids curly braces inside any mapping value, so the parser would reject the whole line outright at load time
- The braces only work inside a
loopvalue and are silently ignored anywhere they appear inside awhenclause
A variable holds the YAML string "false" and a task is gated with when: my_flag. What happens?
- The task runs on every host, because a non-empty string is truthy in Jinja2 regardless of the letters
- The task is skipped, because Jinja2 transparently coerces the quoted word "false" back into a real boolean
- The play fails with a hard type error, because a bare string is never a valid condition for a gate
- The task runs only on the first host in the batch and is quietly skipped on all the rest
A when references a variable that was never set on the current host. What does is defined prevent?
- An undefined-variable error at run time that would otherwise drop the host out of the play, rather than skip the task
- The variable from being garbage-collected out of host scope by the interpreter before the gated task ever gets to run
- Other hosts in the play from reading the same variable concurrently and racing each other on its value
- Ansible from gathering facts a redundant second time on the host once the play is already well underway
What does a YAML list of conditions under a single when: mean?
- Implicit
and— every item must be true for the task to run - Implicit
or— the task runs if any one item is true - The conditions are evaluated in random order until one matches
- Each item gates a different host in the inventory, one per line
You got correct