Skip to main content
RunBook Academy

AnsibleXVII · Templates and Jinja2Templates and Jinja2

Undefined variables and silent wrong output

Advanced⏱ ~20 minansible-playbook

What you'll learn

  • Explain why attribute access on an undefined value does not fail immediately
  • Read a 2.21 templating error and locate the actual missing variable
  • Choose between default(), mandatory and an explicit assertion
  • Recognise a config file that is syntactically valid, passes its own validator, and is wrong

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.

There are three ways a template can handle a variable that is not set, and they sit on a scale from loud to dangerous:

  1. Fail at render time. Nothing is written. Easiest to diagnose.
  2. Fail somewhere else, later. The undefined propagated through several operations before anything objected.
  3. Render a wrong value that looks fine. The file is written, the service accepts it, and behaviour is quietly incorrect.

The third is what this lesson is about, and the uncomfortable part is that the usual defensive habit — adding a default() — is how you get there.

Undefined propagates

Attribute access on an undefined value does not raise. It produces another undefined, which produces another, for as long as you keep accessing attributes.

Read-only / Safean undefined chain, caught by default
$ ansible-playbook -i inv.ini undef2.yml
TASK [Chained access on an undefined, rescued by default] **********************
ok: [localhost] => {
  "msg": "[FALLBACK]"
}

TASK [Chained access on a defined dict missing the key, rescued by default] ****
ok: [localhost] => {
  "msg": "[FALLBACK]"
}

TASK [is defined on a chain] ***************************************************
ok: [localhost] => {
  "msg": "defined=False"
}

missing_thing.some.deep.attribute did not raise on the first missing name. It carried the undefined all the way through, and default() at the end caught it. app.tuning.workers on a dict with no tuning key behaved identically, and is defined on the whole chain returned False rather than erroring.

That is a deliberate and useful design. It is what makes {{ app.tuning.workers | default(4) }} work without checking each level.

The cost is that the point of failure is not the point of the mistake. The undefined was created at missing_thing; if nothing catches it, the error appears wherever the value was finally used — possibly in a different template, possibly in a different task.

What the failure actually looks like

Remove the default() and the same chain fails at the point of use:

Read-only / Safethe error you get in 2.21
$ ansible-playbook -i inv.ini undef.yml
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed:
Error while resolving value for 'msg': 'missing_thing' is undefined

<<< caused by >>>

Error while resolving value for 'msg': 'missing_thing' is undefined
Origin: /path/to/undef.yml:9:14

7     - name: Attribute access on an undefined variable
8       ansible.builtin.debug:
9         msg: "value is [{{ missing_thing.some.deep.attribute }}]"
             ^ column 14

This is worth calling out as an improvement rather than a hazard. On 2.21 the error names the root of the chain — 'missing_thing' is undefined, not some inscrutable complaint about the last attribute — and it points at the exact column. Reading the innermost <<< caused by >>> block first is the fastest route to the cause.

The dangerous case: default('')

Configuration changea template with a defensive default
[database]
host = {{ db_host | default('') }}
port = {{ db_port | default(5432) }}
sslmode = {{ db_sslmode | default('') }}

[limits]
max_connections = {{ db_max_conn | default(100) }}

Suppose db_sslmode was renamed to database_sslmode in a refactor and one group_vars file was missed.

The template renders. The file is written. It contains sslmode = — an empty value that the configuration parser accepts, because an empty string is a legal value for that key. The service starts. The health check passes. The application connects.

Without TLS.

There is no error anywhere. The run is green, the file is on disk, the service is running, the diff shows a one-line change that a reviewer reads as cosmetic, and the connection to the database is now unencrypted.

Configuration changethe same template, honestly
[database]
host = {{ db_host | mandatory }}
port = {{ db_port | default(5432) }}
sslmode = {{ db_sslmode | mandatory }}

[limits]
max_connections = {{ db_max_conn | default(100) }}

Fail earlier than render time

mandatory fails when the template is rendered, which is already after several tasks have run. An assert at the top of the play fails before anything is touched.

Read-only / Safea precondition, not a template error
- name: Deploy the database client configuration
hosts: appservers
become: true

pre_tasks:
  - name: Require the connection settings to be defined
    ansible.builtin.assert:
      that:
        - db_host is defined
        - db_sslmode is defined
        - db_sslmode in ['require', 'verify-ca', 'verify-full']
      fail_msg: >-
        db_host and db_sslmode must be set for {{ inventory_hostname }};
        db_sslmode must be one of require, verify-ca, verify-full
      quiet: true

tasks:
  - name: Deploy the client configuration
    ansible.builtin.template:
      src: db.conf.j2
      dest: /etc/app/db.conf
      owner: root
      group: app
      mode: '0640'
    notify: app config changed

The assertion does something mandatory cannot: it checks the value is allowed, not merely present. db_sslmode: disable passes mandatory and fails the assertion, which is exactly the distinction that matters for a security setting.

This is the guardrails part in miniature. The habit worth taking from it now: for the handful of variables where a wrong value is a security or availability incident, assert them in pre_tasks rather than defending inside every template that uses them.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A template contains {{ app.tuning.workers }} and app is defined as a dict with only a name key. When does this fail?

  2. Q2. A template renders sslmode = {{ db_sslmode | default("") }}. The variable was renamed in a refactor and one group_vars file was missed. What is the outcome?

  3. Q3. Which of these give an earlier or louder failure than default()? Select all that apply.

  4. Q4. enable_tls: "false" written as a quoted string will make {% if enable_tls %} evaluate as true.

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