Skip to main content
RunBook Academy

AnsibleXLV · Debugging and TroubleshootingDebugging and Troubleshooting

Finding out which variable won

Advanced⏱ ~25 minansible-playbookansible-config

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

Not yet marked complete on this device.

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.
Read-only / Safevar, and the templated-var mistake — executed on ansible-core 2.21.3
$ ansible-playbook -i inv.ini dv.yml
TASK [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

Read-only / Safeone host, one variable
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.

Read-only / Safethe same variable across every host in the group
ansible -i inventory/prod appservers \
-m ansible.builtin.debug -a 'var=app_port' -o \
| sed -E 's/.*"app_port": (.*)$/\1/' | sort | uniq -c

3. Ask which groups the host is in

Read-only / Safegroup membership and the group_vars files it implies
- 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_sources

group_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.

Read-only / Safeevery variable the host resolved, written where you can grep it
- 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: localhost

Mode 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.

Read-only / Safefact injection is on by default — read from ansible-config on 2.21.3
$ 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”.

Read-only / Safeonly the settings that are not at their default — executed on 2.21.3
$ ANSIBLE_FORKS=25 ANSIBLE_HOST_KEY_CHECKING=False ansible-config dump --only-changed
CONFIG_FILE() = None
DEFAULT_FORKS(env: ANSIBLE_FORKS) = 25
HOST_KEY_CHECKING(env: ANSIBLE_HOST_KEY_CHECKING) = False

The 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

  1. Q1. A play contains ansible.builtin.debug with var: "{{ app_port }}" where app_port is 8443. What happens on ansible-core 2.21.3?

  2. 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.

  3. Q3. ansible-config dump --only-changed prints CONFIG_FILE() = None as its first line. What should you conclude?

  4. 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.