AnsibleXVII · Templates and Jinja2Templates and Jinja2
Undefined variables and silent wrong output
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
There are three ways a template can handle a variable that is not set, and they sit on a scale from loud to dangerous:
- Fail at render time. Nothing is written. Easiest to diagnose.
- Fail somewhere else, later. The undefined propagated through several operations before anything objected.
- 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.
$ ansible-playbook -i inv.ini undef2.ymlTASK [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:
$ 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 14This 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('')
[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.
[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.
- 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 changedThe 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
Q1. A template contains {{ app.tuning.workers }} and app is defined as a dict with only a name key. When does this fail?
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?
Q3. Which of these give an earlier or louder failure than default()? Select all that apply.
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.