Skip to main content
RunBook Academy

AnsibleXLV · Debugging and TroubleshootingDebugging and Troubleshooting

Reading Jinja and templating errors

Advanced⏱ ~25 minansible-playbook

What you'll learn

  • Read the caused-by chain in a 2.21 templating error down to the originating line
  • Identify undefined, syntax, and type errors from their message shapes
  • Explain why a string false in a when clause fails on 2.21 and what to write instead
  • Distinguish default() from default(omit) and know where omit is meaningless

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.

Templating errors used to be the worst diagnostic experience in Ansible. A Python traceback would surface, name a file that was not the file with the mistake in it, and leave you grepping for braces.

On ansible-core 2.21 that changed, and the change is worth learning deliberately because the new output answers a question the old one did not: where did the offending value come from?

The caused-by chain

Every templating failure on 2.21.3 is reported as a chain of frames, outermost first, each with an Origin: and a source excerpt.

Read-only / Safean undefined variable, in full — executed on ansible-core 2.21.3
$ ansible-playbook -i inv.ini j1.yml
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug'
failed: Error while resolving value for 'msg': 'app_port' is undefined

Origin: /home/ops/estate/j1.yml:4:7

2   gather_facts: false
3   tasks:
4     - name: Undefined variable
      ^ column 7

<<< caused by >>>

Finalization of task args for 'ansible.builtin.debug' failed.
Origin: /home/ops/estate/j1.yml:5:7

3   tasks:
4     - name: Undefined variable
5       ansible.builtin.debug:
      ^ column 7

<<< caused by >>>

Error while resolving value for 'msg': 'app_port' is undefined
Origin: /home/ops/estate/j1.yml:6:14

4     - name: Undefined variable
5       ansible.builtin.debug:
6         msg: 'listening on {{ app_port }}'

Read it bottom-up. The last frame is the innermost cause and the one with the precise location: file, line 6, column 14, with the source line printed. The frames above it are the layers that failed as a consequence: the argument could not be finalised, so the task failed.

The five shapes

Undefined variable

Error while resolving value for 'msg': 'app_port' is undefined

The name is exactly the name that failed to resolve — including, in a chain like item.shell, the part that was missing. It means the variable does not exist at this point in this play for this host, which is not the same as “does not exist in the repository”. The previous lesson is the procedure.

The fix is either to define it or to supply a default, and choosing between those is a design decision: a default hides a missing value, and for something like a port or a hostname you usually want the failure.

Syntax error in the template

Syntax error in template: unexpected 'end of print statement'

Read-only / Safea malformed expression, innermost frame only — executed on 2.21.3
$ ansible-playbook -i inv.ini j5.yml
Error while resolving value for 'msg': Syntax error in template: unexpected
'end of print statement'
Origin: /home/ops/estate/j5.yml:16:14

14     - name: unexpected token
15       ansible.builtin.debug:
16         msg: "{{ 1 + }}"
              ^ column 14

The message names what the parser expected next. unexpected 'end of print statement' means the expression ended early — a missing operand, an unclosed bracket, a filter with no argument. unexpected '}' usually means a nested {{ }} inside an expression that was already an expression.

Filter applied to the wrong type

The filter plugin 'ansible.builtin.join' failed: 'int' object is not iterable

Read-only / Safea filter chain where one step changed the type — executed on 2.21.3
$ ansible-playbook -i inv.ini j2.yml
Error while resolving value for 'msg': The filter plugin 'ansible.builtin.join'
failed: 'int' object is not iterable
Origin: /home/ops/estate/j2.yml:8:14

6     - name: Filter applied to the wrong type
7       ansible.builtin.debug:
8         msg: '{{ app_name | length | join(",") }}'

The named filter is the one that failed, not necessarily the one that is wrong. Here join failed because length before it produced an integer. In a long filter chain, work leftwards from the named filter and print the intermediate values with debug and var: until the type changes to something you did not expect.

A conditional that is not a boolean

This is the shape that changed most on 2.21, and it deserves its own section.

Read-only / Safewhen: enable_tls where enable_tls is the string 'false' — executed on 2.21.3
$ ansible-playbook -i inv.ini j3.yml
A 'when' expression failed.
Origin: /home/ops/estate/j3.yml:9:13

7       ansible.builtin.debug:
8         msg: 'TLS branch taken'
9       when: enable_tls
            ^ column 13

<<< caused by >>>

Conditional result (True) was derived from value of type 'str' at
'/home/ops/estate/j3.yml:4:17'. Conditionals must have a boolean result.
Origin: /home/ops/estate/j3.yml:9:13

Templating delimiters in when

when takes a bare Jinja expression. Writing when: "{{ enable_tls }}" wraps it in an extra evaluation.

Verified on 2.21.3: with enable_tls set to the string 'false', the braces form produced skipping with no error and no warning, where the bare form failed loudly. The two forms are not equivalent, and the braces form is the one that can quietly do the wrong thing.

Write conditionals bare:

Read-only / Safeconditionals, correct and incorrect
# correct — when takes a bare expression
- name: Configure TLS
ansible.builtin.template:
  src: tls.conf.j2
  dest: /etc/app/tls.conf
  mode: '0640'
when: enable_tls | bool

# incorrect — the extra evaluation changes the semantics
- name: Configure TLS
ansible.builtin.template:
  src: tls.conf.j2
  dest: /etc/app/tls.conf
  mode: '0640'
when: "{{ enable_tls }}"

default() versus default(omit)

default(value) substitutes a value. default(omit) substitutes a special marker that tells Ansible to remove the argument entirely, so the module applies its own default.

Read-only / Safeomit, used where it is meaningful
- name: Create accounts, letting the system choose the shell where unspecified
ansible.builtin.user:
  name: '{{ item.name }}'
  shell: '{{ item.shell | default(omit) }}'
  state: present
loop: '{{ users }}'

Knowledge check

Knowledge check · 4 questions

  1. Q1. A templating error on ansible-core 2.21.3 prints three frames separated by caused-by markers. Which frame has the precise location of the mistake?

  2. Q2. On ansible-core 2.21.3, a play sets enable_tls: false as a quoted string and a task has when: enable_tls. What is true? Select all that apply.

  3. Q3. when: "{{ enable_tls }}" and when: enable_tls are equivalent, since Jinja evaluates the expression either way.

  4. Q4. An expression reads {{ app_name | length | join(",") }} and fails with the join filter reporting that an int object is not iterable. Where is the mistake?

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