Skip to main content
RunBook Academy

← All runbooks in Ansible

low riskinformational~35 min

Runbook: Troubleshoot a Jinja error

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The exact error text is captured - the message names both the failing key and the reason, and both are needed
  • · The task name from the failure is known, so the expression can be located
  • · Whether the failure is host-specific is established: a template that fails on one host of twenty is a data problem, not a template problem
  • · It is understood that --syntax-check does not evaluate templates, so a clean syntax check says nothing here

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Read the message and classify it: undefined, attribute, type, or template syntax
  2. 2Locate the expression from the failing task name and the key named in the message
  3. 3Reproduce it in isolation with a debug task against a local inventory - no managed host required
  4. 4For undefined: find whether the variable is absent everywhere or only on this host
  5. 5For attribute: inspect the actual structure of the data rather than the structure you assumed
  6. 6For type: check the type with type_debug, because a quoted number is a string
  7. 7For syntax: read the position the message reports and check quoting at the YAML level first
  8. 8Fix the expression or the data, then render the template in check mode with diff
  9. 9Confirm the rendered output is valid to the consuming program, not merely non-empty

4 · Verification

Confirm the procedure actually fixed the problem.

  • The task completes on the host that failed, and on a host that previously succeeded
  • A check-mode run with --diff shows the rendered content, and it is read rather than skimmed
  • The rendered file passes the consuming programs own validator - a template that renders is not a template that is correct
  • For an undefined-variable fix, the variable now resolves on every host in the group, not just the one that failed

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • Reproduction and inspection change nothing
  • If a template change was applied to hosts, the file has a backup if the task used backup: true - restore it and reload the service
  • If a variable was added to fix an undefined error, confirm the value is correct for every host that now receives it, not just the failing one
  • If default() was added to suppress an undefined error, revisit it - a wrong default that renders silently is worse than a failure
  • Revert the repository change if the fix turns out to change rendered output for other hosts

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the data owner if a fact or an external data source has a different structure than the template expects - the template may be right and the source may have changed
  • · Escalate to the role owner if the failing expression is inside a shared role or collection
  • · Escalate if a rendered config was activated before the error was noticed and a service is now running on it
  • · Escalate if adding default() would let a production configuration render with a placeholder value

Templating failures look intimidating and are usually the fastest class of Ansible fault to fix, because the error names both the key that failed and the reason. The work is reading it precisely and then reproducing it somewhere that costs nothing.

The messages in this runbook were produced by running the failures against ansible-core 2.21.3. They changed shape in recent releases - older material describes a different wording - so match against these rather than against memory.

When to use this runbook

  • A task fails with a templating or rendering error.
  • A when: expression fails rather than evaluating true or false.
  • A template renders but produces content the consuming program rejects.
  • A play works on most hosts and fails on one.

Blast radius

None. Everything here can be reproduced locally against a fake inventory. No managed host is required to diagnose a templating fault, and that is the main practical point of this runbook.

Step 1: Classify the message

Verified against 2.21.3:

Undefined variable

fatal: [web01.example.com]: FAILED! => {"msg": "Task failed: Finalization of
task args for 'ansible.builtin.debug' failed: Error while resolving value for
'msg': 'app_listen_port' is undefined"}

Exit code 2. Read three things: the module, the key (msg), and the name (app_listen_port). The variable does not exist in scope at that point.

Attribute that does not exist

"Error while resolving value for 'msg': object of type 'dict' has no
attribute 'listen_port'"

The variable exists. The structure is not what the expression assumed - cfg.listen_port where cfg is a dict without that key.

Type error

"Task failed: A 'when' expression failed: Error rendering expression:
'>' not supported between instances of 'str' and 'int'"

Both values exist. One is a string where a number was expected, almost always because it was quoted in YAML.

Template syntax error

"Error while resolving value for 'msg': Syntax error in template:
unexpected end of template, expected 'end of print statement'."

The expression itself is malformed - an unclosed {{, a stray quote, a filter without arguments.

Step 2: Locate the expression

The message names the task and the key. Together they locate the expression.

Read-only / Safefind it
# By the task name from the failure
grep -rn 'Render the site configuration' --include='*.yml' roles/ site.yml

# By the variable name, in tasks and in templates
grep -rn 'app_listen_port' --include='*.yml' --include='*.j2' . --exclude-dir=.git

For a template module failure the expression is in the .j2 file, not in the playbook. The message names the module and the destination but not the line, so grep the template.

Step 3: Reproduce in isolation

This is the step that turns a slow investigation into a fast one. Build a two-line inventory that connects to nothing, and iterate on the expression at full speed.

Read-only / Safelocal reproduction inventory
# repro-inv.yml
all:
vars:
  ansible_connection: local
  ansible_python_interpreter: "{{ ansible_playbook_python }}"
hosts:
  testhost:
Read-only / Saferepro.yml
- name: Reproduce the templating failure
hosts: testhost
gather_facts: false
vars:
  cfg:
    listen: 8080
  servers:
    - name: a
    - name: b
tasks:
  - name: The expression that failed
    ansible.builtin.debug:
      msg: "{{ cfg.listen_port }}"
    ignore_errors: true

  - name: What is actually in there?
    ansible.builtin.debug:
      msg: "keys={{ cfg.keys() | list }} type={{ cfg | type_debug }}"
Read-only / Safeiterate
ansible-playbook -i repro-inv.yml repro.yml

Sub-second, no network, no managed host, no risk. Paste the real data structure in and iterate on the expression until it renders.

Step 4: Undefined - absent everywhere, or absent here?

Read-only / Safescope the absence
# Does any host have it?
ansible all -m debug -a 'var=app_listen_port' -o | head -20

# Where is it set?
grep -rn --include='*.yml' -E '^\s*app_listen_port\s*:' . --exclude-dir=.git
FindingCauseFix
No host has itNever defined, or a name typoDefine it, or fix the name
Some hosts have itA group_vars file the failing host’s groups do not includeMove it to a group that covers every consumer, or add a role default
Only fails inside a roleThe role’s defaults/main.yml does not declare itAdd it to defaults/, which is the role’s interface
Fails only on the first runIt is set by set_fact in a task that was skippedCheck the when: on that task

Step 5: Attribute - inspect the real structure

Do not read the expression again. Print the data.

Read-only / Safewhat shape is it actually
- name: Show the structure, not the assumption
ansible.builtin.debug:
  msg: |
    type:  {{ cfg | type_debug }}
    keys:  {{ cfg.keys() | list if cfg is mapping else 'not a mapping' }}
    value: {{ cfg | to_nice_json }}
Read-only / Safefor facts
ansible web01.example.com -m setup -a 'filter=ansible_default_ipv4' -o
ansible web01.example.com -m setup -a 'filter=ansible_mounts' -o | head -30

Facts are the usual source of this failure. ansible_default_ipv4 is a dict on a host with a default route and an empty dict on a host without one, so ansible_default_ipv4.address fails on exactly the hosts with unusual networking - which is why it works on nineteen hosts and fails on the twentieth.

Read-only / Safedefensive access to facts
- name: Address, with an honest fallback
ansible.builtin.debug:
  msg: >-
    {{ ansible_facts['default_ipv4']['address']
       | default(ansible_facts['all_ipv4_addresses'] | first) }}

Step 6: Type - check it, do not assume it

Read-only / Safetype_debug
- name: What types are these really?
ansible.builtin.debug:
  msg: >-
    app_port={{ app_port }} ({{ app_port | type_debug }})
    threshold={{ threshold }} ({{ threshold | type_debug }})

Verified on 2.21.3: 8080 | type_debug is int, '8080' | type_debug is str, and comparing them raises the type error shown in Step 1.

YAML decides the type, and quoting is not cosmetic:

Verified on 2.21.3 by rendering each through type_debug:

port: 8080        -> int 8080
port: "8080"      -> str "8080"
enabled: yes      -> bool True
enabled: "yes"    -> str "yes"
version: 1.10     -> float 1.1        (the trailing zero is gone)
version: "1.10"   -> str "1.10"
mode: 0644        -> int 420          (parsed as YAML 1.1 octal, = 0o644)
mode: '0644'      -> str "0644"

The version row costs real time: a version number written as a bare 1.10 becomes the float 1.1, and every comparison and every rendered config line afterwards carries the wrong value with no error anywhere.

File modes are the subtler one. 0644 is parsed as octal by the YAML 1.1 rules PyYAML implements, giving the integer 420 - which is 0o644, so it happens to be right. But 644 without the leading zero is decimal 644, which is 0o1204: the sticky bit set and permissions that are not what anyone intended. The task reports success either way.

Quote file modes. mode: '0644' is unambiguous under every parser, and it is the one form that does not depend on knowing which of those two rows you wrote.

Convert explicitly at the point of comparison rather than hoping:

Read-only / Safeexplicit conversion
- name: Compare like with like
ansible.builtin.debug:
  msg: "over threshold"
when: (app_port | int) > (threshold | int)

Step 7: Syntax - check the YAML layer first

Jinja syntax errors are frequently YAML quoting errors wearing a disguise.

# Fails: YAML sees a flow mapping, not a Jinja expression
msg: {{ app_port }}

# Correct: quote the whole scalar when it starts with {{
msg: "{{ app_port }}"

# Fails: the inner double quotes end the outer scalar
msg: "{{ lookup("env", "HOME") }}"

# Correct: single quotes inside, double outside
msg: "{{ lookup('env', 'HOME') }}"

# Fails: unterminated expression
msg: "{{ app_port "

# Multi-line expressions: use a folded scalar
msg: >-
  {{ servers | map(attribute='name') | join(',') }}

A value that begins with {{ must be quoted. That single rule accounts for most of the syntax errors in this class.

Step 8: Rendering is not correctness

Read-only / Saferead the rendered content
ansible-playbook -i inventories/production site.yml \
--limit web01.example.com --tags config --check --diff
Configuration changevalidate at render time
- name: Render the site configuration, validated before activation
ansible.builtin.template:
  src: site.conf.j2
  dest: /etc/nginx/conf.d/site.conf
  owner: root
  group: root
  mode: '0644'
  backup: true
  validate: 'nginx -t -c %s'
notify: Reload nginx

validate: runs the checker against the rendered file before it replaces the live one. A template bug then fails the task with the program’s own error message, and the running configuration is untouched. It does not run in check mode - there is no rendered file to validate - so it is a real-run protection, not a dry-run one.

Verification

Read-only / Safeverify
# The failing host now succeeds
ansible-playbook -i inventories/production site.yml \
--limit web01.example.com --check --diff

# A previously-working host is unchanged
ansible-playbook -i inventories/production site.yml \
--limit web02.example.com --check --diff

# Every host in the group renders
ansible-playbook -i inventories/production site.yml \
--limit web --check | tail -10

The middle check matters. A fix that adds a variable or changes an expression changes rendering for every host that uses the template, and the one you were debugging is not the one at risk.

Common patterns

SymptomLikely causeResolution
'x' is undefinedNot set in scope; often a role default missingScope the absence across hosts first
object of type 'dict' has no attribute 'y'The structure differs from the assumptionPrint the structure with to_nice_json
Fails on one host of twentyFact absent on that host - no default route, no mountsDefensive access with default()
'>' not supported between instances of 'str' and 'int'A quoted number in YAMLtype_debug; convert with | int
Syntax error in templateUnquoted {{ at the start of a value, or nested quotesQuote the scalar; single quotes inside
--syntax-check passes, the run failsTemplates are not rendered at syntax-check timeReproduce with a local debug task
Renders fine, service will not startStructurally valid, semantically wrong outputvalidate: on the template task
File mode is wrong though the task succeededmode: 644 is decimal 644, which is 0o1204Quote it: mode: '0644'

Escalation

Escalate when:

  • A fact or external data source changed shape. The template may be correct and the source may have moved.
  • The failing expression is in a shared role or collection.
  • A wrong rendered config was already activated on a service.
  • Adding default() would let production render with a placeholder.

References

  1. Templating (Jinja2)
  2. Jinja template designer documentation
  3. ansible.builtin.template module