Chapter 7: Control Flow
Topic 39

Conditionals and when

ConceptControl Flow

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.

A task gated per host — runs only on Ubuntu, skips everything else
- 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.

What when actually evaluates — three values that look alike
the string "false"
Truthy. A non-empty string is true in Jinja2 no matter what the letters spell, so the task runs — the off switch that doesn't switch off.
the boolean false
Falsy. An unquoted YAML false reaches Jinja2 as real False, so the task skips — the off switch that works.
is defined
Existence, not value. Tests whether the variable exists at all, guarding against the undefined-variable error that drops the host.

Definedness 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.

A YAML list under when is implicit AND — both conditions must hold
- 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.

Common Mistakes
  • Setting install_extras: "false" in group_vars and gating a task with when: 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 > 5432 on a host where db_port was never set — the play fails with an undefined-variable error instead of skipping; guard it with db_port is defined and ... first.
  • Writing when: result.changed or result.failed and assuming result has those keys even when the registering task was skipped — a skipped task registers a result with different keys, so test result.skipped is not defined before 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 the or branch silently never fires.
Best Practices
  • Write conditions as bare expressions with no {{ }}, and let ansible-lint catch the braced ones that slip through review.
  • Store real booleans — true / false unquoted — in any var meant as a flag, and never rely on "false" the string to disable anything.
  • Guard every optional variable with is defined or | default(...) so a when never reads an undefined value and aborts the run.
  • Use a YAML list under when: for the common all-must-hold case, and reserve explicit and / or with parentheses for genuinely mixed logic.
  • Coerce string-typed inputs from --extra-vars with | bool before testing them, so a value passed on the command line behaves like the boolean you intended.
Comparable tools Puppet 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 loop value and are silently ignored anywhere they appear inside a when clause

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