AnsibleXLV · Debugging and TroubleshootingDebugging and Troubleshooting
Finding out which variable won
What you'll learn
- Use debug var and msg correctly and explain why they are not interchangeable
- Read hostvars and group_names to trace a value across hosts
- Distinguish ansible_facts entries from the injected ansible_ variables
- Use ansible-config dump --only-changed to find configuration that changes behaviour
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
A template rendered 8080 where you expected 8443. Twenty-two places
in the repository could have set that value, and the precedence order
has twenty-two entries.
The instinct is to read the precedence table. The faster route is to ask the host what it resolved and work backwards, because the answer to “what is the value” is one task away and the answer to “which of these twenty-two files applies here” requires knowing which files exist for this host in the first place.
var and msg are not interchangeable
debug takes either. They behave differently, and the difference trips
people every time they switch between them.
var:takes a variable name. No braces. Ansible looks the name up and prints the value with its type preserved.msg:takes a string, which you template. Everything becomes text.
$ ansible-playbook -i inv.ini dv.ymlTASK [var takes a name, not an expression] *************************************
ok: [lh] => {
"app_port": 8443
}
TASK [var with a templated value is a common mistake] **************************
fatal: [lh]: FAILED! => {"msg": "argument 'var' is of type int and we were unable
to convert to _check_type_str_no_conversion: '8443' is not a string and conversion
is not allowed"}var: "{{ app_port }}" templates first, so var receives the value
8443 and then tries to look up a variable called 8443. On 2.21.3
that fails with a type error rather than silently printing something
unhelpful, which is a genuine improvement — the same mistake on older
releases produced output that looked almost right.
Use var: when you want to see structure and type. Use msg: when you
want a sentence. If you find yourself writing var: "{{ ... }}", you
wanted msg:.
The four-step trace
The procedure, in the order that resolves the most cases fastest.
1. Ask the host what it resolved
ansible -i inventory/prod app-047.example.com \
-m ansible.builtin.debug -a 'var=app_port'2. Compare it across the group
If one host disagrees with the others, the source is host-specific. If they all agree and all disagree with what you expected, the source is higher up.
ansible -i inventory/prod appservers \
-m ansible.builtin.debug -a 'var=app_port' -o \
| sed -E 's/.*"app_port": (.*)$/\1/' | sort | uniq -c3. Ask which groups the host is in
- name: What could have set this
hosts: app-047.example.com
gather_facts: false
tasks:
- name: The groups this host is in
ansible.builtin.debug:
var: group_names
- name: The inventory files that were parsed
ansible.builtin.debug:
var: ansible_inventory_sourcesgroup_names is the shortlist. A host in all, appservers,
prod and eu_west can only have picked up a group variable from one
of those four group_vars files, and the precedence between them is a
much smaller question than the full table.
4. Look at everything, once
When the shortlist does not explain it, dump the lot and read.
- name: Dump one host resolved variables
hosts: app-047.example.com
gather_facts: true
tasks:
- name: Write the resolved variable set to the controller
ansible.builtin.copy:
content: "{{ hostvars[inventory_hostname] | to_nice_yaml }}"
dest: './artifacts/{{ inventory_hostname }}-vars.yaml'
mode: '0600'
delegate_to: localhostMode 0600 is not decoration. That dump contains every variable the
host resolved, which includes anything Vault decrypted for the play.
ansible_facts versus the injected ansible_ variables
Two names for the same data, and the difference matters when you are reading someone else’s play.
$ ansible-config list | grep -A 8 '^INJECT_FACTS_AS_VARS'INJECT_FACTS_AS_VARS:
default: true
description:
- Facts are available inside the `ansible_facts` variable, this setting also pushes
them as their own vars in the main namespace.
- Unlike inside the `ansible_facts` dictionary where the prefix `ansible_` is removed
from fact names, these will have the exact names that are returned by the module.So ansible_facts['os_family'] and ansible_os_family are the same
fact, reached two ways. The dictionary form is canonical; the injected
form exists for compatibility and is what most older material uses.
Three consequences for debugging:
The injected names live in the ordinary variable namespace, so an
inventory variable called ansible_os_family collides with the fact.
The ansible_facts form does not have that problem, which is the
argument for preferring it.
A play written against injected names breaks if injection is turned
off. Estates do turn it off, precisely to avoid the collision above.
If a play works on one controller and not another, INJECT_FACTS_AS_VARS
belongs on the list of things to compare.
Not everything named ansible_ is a fact. ansible_host,
ansible_user, ansible_port and ansible_connection are connection
variables set from the inventory, not gathered from the host. They will
not appear in ansible_facts, and looking for them there is a common
dead end.
ansible-config dump --only-changed is the first question
Two hundred and nineteen configuration settings exist. About six of them differ on your controller, and those six explain a large share of “it behaves differently here”.
$ ANSIBLE_FORKS=25 ANSIBLE_HOST_KEY_CHECKING=False ansible-config dump --only-changedCONFIG_FILE() = None
DEFAULT_FORKS(env: ANSIBLE_FORKS) = 25
HOST_KEY_CHECKING(env: ANSIBLE_HOST_KEY_CHECKING) = FalseThe parenthetical is the valuable part. (env: ANSIBLE_FORKS) says the
value came from the environment, not from ansible.cfg — so it is set
in a shell profile, a CI job definition or a wrapper script, and it will
not be found by reading the repository.
Knowledge check
Knowledge check · 4 questions
Q1. A play contains ansible.builtin.debug with var: "{{ app_port }}" where app_port is 8443. What happens on ansible-core 2.21.3?
Q2. ansible_os_family and ansible_facts[os_family] refer to the same gathered fact, reachable two ways because fact injection is enabled by default.
Q3. ansible-config dump --only-changed prints CONFIG_FILE() = None as its first line. What should you conclude?
Q4. A template rendered the wrong port on one host out of forty. Which steps narrow the cause fastest? Select all that apply.
Passing score: 75%. Answers are checked in this browser.