Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesFact gathering

Reading ansible_facts properly

Intermediate⏱ ~21 minansible-playbookansible-config

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

Not yet marked complete on this device.

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:

Read-only / Safewhat setup actually returns
$ 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:

FormExampleWhere it comes from
Namespacedansible_facts.distributionThe fact dictionary itself. Always present.
Injectedansible_distributionA 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

Read-only / Safethe setting and its documented shape
$ 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'
Read-only / Safeconfirming a setting actually took effect
$ ANSIBLE_INJECT_FACT_VARS=False ansible-config dump | grep INJECT_FACTS
INJECT_FACTS_AS_VARS(env: ANSIBLE_INJECT_FACT_VARS) = False

The 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:

Read-only / Safethe warning you will start seeing
$ ansible-playbook inject.yml
TASK [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:

Read-only / Safethe same play with injection disabled
$ ansible-playbook inject.yml
TASK [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.

Read-only / Safethe form that survives
# 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:

Read-only / Safetemplating a config with the database host address
- 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

  1. Q1. Inside the ansible_facts dictionary, what is the correct key for the distribution fact?

  2. Q2. An operator sets ANSIBLE_INJECT_FACTS_AS_VARS=False and observes that ansible_distribution still resolves. What is the explanation?

  3. Q3. Which statements about the INJECT_FACTS_AS_VARS deprecation in ansible-core 2.21 are accurate? Select all that apply.

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