AnsibleXIV · Facts and Registered VariablesFact gathering
Reading ansible_facts properly
What you'll learn
- Distinguish the ansible_facts dictionary from the injected top-level ansible_ names
- Explain what INJECT_FACTS_AS_VARS does and why its default is deprecated
- Read a fact from hostvars for a host other than the one executing
- Choose the addressing form that survives the 2.24 removal
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
Almost every Ansible repository in existence writes this:
when: ansible_distribution == 'Ubuntu'
and almost every one of them will need to change. That name is not
where the fact lives. It is a copy, injected into the top-level
variable namespace by a setting whose default is deprecated and whose
removal is scheduled for ansible-core 2.24.
This lesson is about the difference between the two, why the difference exists, and which form to write today.
Two names for the same value
The setup module returns one dictionary, ansible_facts. Inside it,
keys are stored without the ansible_ prefix:
$ ansible localhost -m ansible.builtin.setup -a "gather_subset=!all,!min,distribution"localhost | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_major_version": "26",
"ansible_distribution_release": "resolute",
"ansible_distribution_version": "26.04",
"ansible_os_family": "Debian",
"gather_subset": [
"!all",
"!min",
"distribution"
],
"module_setup": true
},
"changed": false
}Once those facts are merged into the host’s variables, there are two ways to reach the same value:
| Form | Example | Where it comes from |
|---|---|---|
| Namespaced | ansible_facts.distribution | The fact dictionary itself. Always present. |
| Injected | ansible_distribution | A top-level copy, created only if INJECT_FACTS_AS_VARS is true. |
Note the prefix rule, because it is the detail that trips people:
inside ansible_facts the ansible_ prefix is stripped. The fact
is ansible_facts.distribution, not ansible_facts.ansible_distribution.
Upstream states this directly — inside the dictionary the prefix is
removed, whereas the injected copies “have the exact names that are
returned by the module”.
The setting
$ ansible-config list | grep -A 13 '^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.
env:
- name: ANSIBLE_INJECT_FACT_VARS
ini:
- key: inject_facts_as_vars
section: defaults
type: boolean
version_added: '2.5'$ ANSIBLE_INJECT_FACT_VARS=False ansible-config dump | grep INJECT_FACTSINJECT_FACTS_AS_VARS(env: ANSIBLE_INJECT_FACT_VARS) = FalseThe parenthesised source is the useful part. (default) means your
override did nothing. (env: ...) or a path to an ansible.cfg means
it landed.
The deprecation
Running a play that reads an injected fact on ansible-core 2.21
produces this:
$ ansible-playbook inject.ymlTASK [Injected short form] *****************************************************
[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to 'True' is deprecated,
top-level facts will not be auto injected after the change. This feature will
be removed from ansible-core version 2.24.
Origin: inject.yml:12:14
10 - name: Injected short form
11 ansible.builtin.debug:
12 msg: "ansible_distribution is defined: {{ ansible_distribution is defined }}"
^ column 14
ok: [localhost] => {
"msg": "ansible_distribution is defined: True"
}Read the warning precisely, because it is narrower than it first appears. What is deprecated is the default being true, not the setting. After 2.24, injection will be off unless you turn it on. The setting itself is not being removed in that change.
That still means every ansible_distribution in your repository stops
resolving on the day you upgrade past it, unless you have explicitly
set inject_facts_as_vars = True in ansible.cfg.
Here is what turning it off does today:
$ ansible-playbook inject.ymlTASK [Namespaced form always works] ********************************************
ok: [localhost] => {
"msg": "ansible_facts.distribution = Ubuntu"
}
TASK [Injected short form] *****************************************************
ok: [localhost] => {
"msg": "ansible_distribution is defined: False"
}Not “undefined and errors”. Not “empty string”. is defined returns
false, which means a when: ansible_distribution == 'Ubuntu' becomes a
comparison against an undefined name, and a
when: ansible_distribution is defined silently stops matching any
host.
Which form to write
Write ansible_facts.distribution.
It works today with injection on, works today with injection off, works
after 2.24 with no change, and is self-documenting: a reader can tell
at a glance that the value came from fact gathering rather than from
group_vars, which the bare name does not tell them.
# Survives 2.24. Says where the value came from.
- name: Install using the distribution package manager
ansible.builtin.package:
name: chrony
state: present
when: ansible_facts['os_family'] in ['Debian', 'RedHat']
# Works today. Stops resolving when injection is off.
- name: Install using the distribution package manager
ansible.builtin.package:
name: chrony
state: present
when: ansible_os_family in ['Debian', 'RedHat']Both bracket and dot notation reach the same value:
ansible_facts['distribution'] and ansible_facts.distribution are
equivalent. Bracket notation is the safer default, for the same reason
it is in Python: a fact name that collides with a dictionary method —
ansible_facts.keys would be one — resolves to the method under dot
notation and to the fact under brackets. This is rare with real fact
names, but the habit costs nothing.
Reading another host’s facts
Facts are per-host. A task running on web01 sees web01’s facts.
Reading a fact belonging to a different host goes through hostvars,
a magic variable keyed by inventory hostname:
- name: Show the address the app tier should connect to
ansible.builtin.debug:
msg: >-
database is {{ hostvars['db01']['ansible_facts']['default_ipv4']['address'] }}
# Across a whole group, without hardcoding a hostname:
- name: List every web host address
ansible.builtin.debug:
msg: >-
{{ groups['webservers']
| map('extract', hostvars, ['ansible_facts', 'default_ipv4', 'address'])
| list }}This is the single most useful thing facts do, and it is also where the biggest trap in this part lives.
Knowledge check
Knowledge check · 4 questions
Q1. Inside the ansible_facts dictionary, what is the correct key for the distribution fact?
Q2. An operator sets ANSIBLE_INJECT_FACTS_AS_VARS=False and observes that ansible_distribution still resolves. What is the explanation?
Q3. Which statements about the INJECT_FACTS_AS_VARS deprecation in ansible-core 2.21 are accurate? Select all that apply.
Q4. A play targeting hosts: webservers can read hostvars['db01'].ansible_facts.default_ipv4.address without db01 appearing in any play in that run.
Passing score: 75%. Answers are checked in this browser.