skip to content

Variables and Templating

Where a value came from is the perennial Ansible question — a long precedence order, set_fact, and Jinja2 templating over the top. Interviewers ask you to debug an unexpected value rather than recite the 22 levels.

on this pageshow

questions

6

In an Ansible playbook, what does the Jinja2 expression `{{ myvar | default('fallback') }}` do, and how do `default('fallback', true)` and `default(omit)` differ from it?

level: juniorimportance: must knowfreq 58%

answer

  1. undefined is not the same as empty
  2. second argument widens it to falsy
  3. a magic value deletes the parameter
  4. zero and false are legitimate values
  5. is defined for branching, default for substituting

basics

~20 s

The default filter supplies a value only when the variable is undefined. Passing true as a second argument also replaces empty or otherwise falsy values. Passing the special omit value removes the parameter from the task entirely, so the module applies its own default.

solid answer

~40 s

`default('fallback')` fires only on *undefined* — if `myvar` exists but is an empty string, `0` or `false`, you get that value, not the fallback. Adding a truthy second argument, `default('fallback', true)`, widens it to any falsy value, which is what you usually want for a variable that may arrive as an empty string from inventory. `default(omit)` is Ansible-specific rather than plain Jinja: `omit` is a magic value that makes the whole parameter disappear from the module call, so the module's own default applies instead of you guessing it. That is the correct way to make an optional module argument optional — passing an empty string instead would set the owner or group to `''` rather than leaving it alone. For a check rather than a substitution, use the Jinja test `is defined`.

code

yaml · 14 lines
yaml
- name: Optional parameters done right
  ansible.builtin.file:
    path: /srv/app
    state: directory
    owner: "{{ app_owner | default(omit) }}"
    mode: "{{ app_mode | default(omit) }}"

- name: Empty string from inventory still needs a fallback
  ansible.builtin.debug:
    msg:
      - "undefined only: {{ app_port | default(8080) }}"
      - "falsy too:     {{ app_port | default(8080, true) }}"
  vars:
    app_port: ""

go deeper

for a junior

Know that default() supplies a value only when the variable does not exist, and that a variable set to an empty string still counts as existing. Be able to write a safe reference in a template.

for a middle

Explain the three cases cleanly — undefined, falsy, absent — and say why omit is the correct way to make a module parameter optional instead of passing an empty string.

for a senior

Show judgment about where defaults belong: role defaults/main.yml declares the interface, inline default() covers genuinely optional values, and boolean mode is dangerous where 0 or false is meaningful.

for a principal

Own the convention across the estate: which values must be asserted rather than defaulted, so a missing region or account id fails the run loudly instead of quietly rendering a plausible wrong config.

## The problem Ansible variables are frequently optional: a role ships without an opinion, and inventory may or may not supply one. If a template or a task references a name that was never defined, the run fails — by default `error_on_undefined_vars` is true in `ansible.cfg`, and an undefined variable in a task is a fatal error for that host. The `default` filter is the standard way to make a reference safe. ## Plain `default(value)` — undefined only ```yaml - ansible.builtin.debug: msg: "{{ app_port | default(8080) }}" ``` If `app_port` is not defined anywhere, this renders `8080`. If it *is* defined, whatever it holds is used — including an empty string, `0`, `false`, or an empty list. This surprises people constantly, because YAML makes it very easy to produce an empty value: ```yaml app_port: # this is defined, and its value is null (None) ``` That key is defined with a `None` value, so `default(8080)` does **not** fire and the module receives `None`. ## `default(value, true)` — any falsy value The second positional argument is the *boolean* mode from Jinja: when truthy, the filter substitutes whenever the input is falsy, not merely undefined. ```yaml "{{ app_port | default(8080, true) }}" ``` Now an empty string, `null`, `0`, `false` and an empty list all yield `8080`. This is the right choice for values arriving from a templated inventory, a CI environment variable, or a `vars_prompt` the operator may have skipped. It is the *wrong* choice when `0` or `false` is a legitimate value you must be able to set — `replicas: 0` silently becoming the default is a nastier bug than a crash. ## `default(omit)` — remove the parameter `omit` is a special Ansible variable, not a Jinja concept. When a module parameter's value ends up being `omit`, Ansible strips that parameter from the task before invoking the module: ```yaml - ansible.builtin.file: path: /srv/app state: directory owner: "{{ app_owner | default(omit) }}" mode: "{{ app_mode | default(omit) }}" ``` If `app_owner` is undefined, the `owner` parameter is not sent at all, so the `file` module leaves ownership as it found it. Compare the alternatives: `default('')` would send an empty owner (an error or an unwanted change), and hardcoding `default('root')` bakes in an opinion the module already has a better answer for. `omit` is therefore the idiomatic way to write a role with genuinely optional pass-through parameters, and it works inside loops too — each iteration is evaluated separately. One limit worth knowing: `omit` only has this effect where a module parameter is being built. Inside a template file or a `debug` message it has no special meaning and renders as an internal placeholder string. ## Checking rather than substituting When the branch matters more than the value, use Jinja tests in a `when`: ```yaml - name: Only when a certificate was supplied ansible.builtin.copy: src: "{{ tls_cert_path }}" dest: /etc/ssl/app.pem when: tls_cert_path is defined ``` `is defined` / `is undefined` test existence; `is none` tests for a null value; `| length > 0` tests emptiness. Note that `when` expressions are already Jinja, so they are written bare, without `{{ }}`. ## Chaining and readability `default` chains left to right, which gives a clean fallback ladder: ```yaml "{{ host_timeout | default(group_timeout) | default(30) }}" ``` It is tempting to write these everywhere, but a role littered with inline defaults is harder to reason about than one that declares its defaults once in `defaults/main.yml` — the lowest precedence source, designed exactly for this. Use `defaults/main.yml` for the role's stated interface, and reserve inline `default()` for values that are genuinely optional at the point of use, for `omit` cases, and for defensive access to facts that may not exist on every platform (`ansible_facts.get('lsb', {})`-style guards). ## Interview framing The distinction interviewers listen for is undefined versus falsy versus absent: `default(x)` covers undefined, `default(x, true)` covers falsy, `default(omit)` covers "do not send this at all". A candidate who knows only the first will eventually ship a task that sets a permission to an empty string.

  • Why is default(omit) preferred over default('') for an optional owner or group parameter?
    Because they mean different things to the module. `default('')` still sends the parameter, with an empty value, which is either rejected or applied as a change. `omit` removes the parameter from the task entirely, so the module falls back to its own documented default and leaves the existing value untouched — which is what "optional" actually means.
  • A variable is set in inventory but the key has no value. Does default() fire?
    No. `app_port:` with nothing after it is a defined key whose value is `null`, so plain `default(8080)` passes the `null` straight through. Either add the boolean second argument, `default(8080, true)`, or fix the inventory. This is the classic empty-versus-undefined trap.
  • How would you fail the run deliberately when a required variable is missing, rather than defaulting it?
    Assert it early in the play with the `assert` module and a `myvar is defined` condition, or reference it with `| mandatory`. Failing loudly at the top of the play is far better than discovering three tasks later that a template rendered a blank hostname into a config file.

saying these in an interview costs you the question

  • Thinks default() also replaces empty strings
  • Uses default('') for an optional module parameter
  • Believes omit is standard Jinja rather than Ansible-specific
  • Uses default(false, true) where false is a valid value
  • Confuses is defined with is none

context

open as a page

In Ansible, a variable resolves to an unexpected value at runtime. How does Ansible's variable precedence decide which definition wins, and how do you trace where the value actually came from?

level: middleimportance: must knowfreq 72%

basics

~20 s

Ansible layers about 22 variable sources from role defaults at the bottom to command-line extra vars at the top, which always win. Facts sit below play vars; set_fact and registered vars sit near the top. Trace a value with the debug module and ansible-inventory.

open as a page

In Ansible, when do you reach for set_fact versus register versus vars_files, and what is the scope and lifetime of a variable created by set_fact?

level: middleimportance: should knowfreq 55%

basics

~20 s

register captures a task's result object for the host that ran it; set_fact computes a named value per host at run time; vars_files loads static YAML at the start of a play. A set_fact value is per host and survives into later plays of the same run, but not into the next run unless cacheable is set.

open as a page

An Ansible play sets `gather_facts: no` to speed up a run, and a later task referencing `ansible_facts['distribution']` fails with an undefined variable. Why does that happen, and what are your options?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Facts are produced by the setup module, which Ansible runs implicitly at the start of a play. Disabling gathering skips it, so no ansible_facts exist. Options are to re-enable gathering, run setup explicitly for the hosts that need it, narrow it with gather_subset, or enable fact caching.

open as a page

In an Ansible play running against your web servers, how do you use a value belonging to a different host — say the IP address of a database server in another group — and what has to be true for that to work?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Read it through the hostvars magic variable, indexed by the other host's inventory name, and find the hosts with groups. Inventory-defined values are always there, but another host's facts only exist if that host was played in this run or its facts are cached.

open as a page

In an Ansible playbook targeting a remote server, where does `{{ lookup('file', '/etc/motd') }}` read that file from — the control node or the managed host?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

From the control node. Every Ansible lookup plugin runs locally, in the controller's Python process, using the controller's filesystem, environment and credentials — never on the managed host, regardless of which host the task targets.

open as a page