Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesFact gathering

Which facts you can trust

Intermediate⏱ ~23 minansible

What you'll learn

  • Rank a fact by how likely it is to still be true when you act on it
  • Distinguish a fact being absent from a fact being empty
  • Write conditionals that survive a host where the fact was never collected
  • Explain why a fact says what a host reports rather than what is true

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.

The word “fact” is doing a lot of persuasive work.

A fact is a value a fact module produced by asking the host a question, at one moment, through one mechanism. Some of those mechanisms are as close to ground truth as you can get. Others are a best guess from a heuristic that was written before the situation you are in existed. They arrive in the same dictionary, formatted identically, with nothing to distinguish them.

Treating all 121 of them as equally authoritative is how a play ends up choosing dnf on a Debian host, or sizing a JVM heap from a memory figure that was true before the VM was resized.

The reliability ranking

Facts sort into four bands. The band matters more than the individual fact, because it tells you what kind of mistake you are exposed to.

Band 1: read directly from a file the OS maintains

ansible_facts.distribution, distribution_version, distribution_release, os_family. These come from /etc/os-release and its predecessors. The fact even tells you which file it used:

Read-only / Safethe distribution collector shows its working
$ ansible localhost -m ansible.builtin.setup -a "gather_subset=!all,!min,distribution"
localhost | SUCCESS => {
  "ansible_facts": {
      "ansible_distribution": "Ubuntu",
      "ansible_distribution_file_parsed": true,
      "ansible_distribution_file_path": "/etc/os-release",
      "ansible_distribution_file_variety": "Debian",
      "ansible_distribution_major_version": "26",
      "ansible_distribution_release": "resolute",
      "ansible_distribution_version": "26.04",
      "ansible_os_family": "Debian"
  },
  "changed": false
}

distribution_file_parsed: true is the fact telling you it is confident. On an exotic or damaged host it comes back false and the distribution values are a fallback guess — which is a check worth making in an assertion before you branch on the distribution across a mixed fleet.

These are the most trustworthy facts available and they are still only as true as /etc/os-release, which is a text file that an in-place distribution upgrade rewrites and a badly built container image sometimes inherits from the wrong base.

Band 2: stable identity that outlives a reboot

ansible_facts.machine_id, ansible_facts.architecture, ansible_facts.userspace_bits, the SSH host key facts.

machine_id is the one worth knowing about. It comes from /etc/machine-id, it is stable across reboots, and it is not stable across cloning — a VM template that baked in a machine-id produces a fleet of hosts sharing one identity. That is a real condition that shows up as strange behaviour in anything keyed on it, and Ansible will report the duplicate value without comment.

Band 3: current state, true right now, possibly not in five minutes

Memory, CPU count, mounts, block devices, addresses, uptime, date.

Read-only / Safefacts that describe a moment
$ ansible localhost -m ansible.builtin.setup -a "filter=ansible_memtotal_mb,ansible_processor_vcpus,ansible_processor_cores"
localhost | SUCCESS => {
  "ansible_facts": {
      "ansible_memtotal_mb": 47145,
      "ansible_processor_cores": 12,
      "ansible_processor_vcpus": 12
  },
  "changed": false
}

These are correct and they are also the ones that produce quiet damage, because the mistake is not reading them — it is persisting a decision made from them. A template that writes a JVM heap size computed from memtotal_mb records that number in a config file. The VM is resized. The config still says the old number, and nothing re-runs until someone notices the process is undersized on a machine with twice the RAM.

The defence is to write the rule into the config rather than the result where the target supports it, and where it does not, to run the play that recomputes it as part of the resize procedure. That is a runbook problem, not a templating problem.

Band 4: heuristics and best guesses

ansible_facts.virtualization_type, virtualization_role, service_mgr, pkg_mgr, is_chroot.

These are inferred, not read. The virtualisation facts in particular work by looking for signatures — hypervisor strings in DMI data, device names, cgroup contents — and they get less reliable the more layers of abstraction sit between the kernel and the metal.

Read-only / Safean inferred fact reporting something odd
$ ansible localhost -m ansible.builtin.setup -a "filter=ansible_virtualization_*"
localhost | SUCCESS => {
  "ansible_facts": {
      "ansible_virtualization_role": "guest",
      "ansible_virtualization_tech_guest": [
          "kvm"
      ],
      "ansible_virtualization_tech_host": [
          "kvm"
      ],
      "ansible_virtualization_type": "kvm"
  },
  "changed": false
}

That output is real, from the machine this lesson was written on. It reports the host as a KVM guest and simultaneously reports KVM host technology — because the machine is a guest that itself has KVM available. Neither value is wrong. A conditional written as when: ansible_facts.virtualization_role == 'host' to mean “this is bare metal” would be correct here by luck, and wrong on a bare-metal host with KVM installed.

The rule for band 4: do not branch a change on an inferred fact when inventory could answer the question instead. Whether a host is a hypervisor is a fact about your estate that you know, and it belongs in a group. Lesson 7 of Part XV makes this argument in full.

Absent is not the same as empty

The most common fact-related failure is not a wrong value. It is reading a key that is not there, or is there and empty.

Three distinct states, which conditionals routinely conflate:

StateExampleis defined
Never collectedmounts when gather_subset excluded hardwarefalse
Collected, no datadefault_ipv4 on a host with no default routetrue
Collected, has datadefault_ipv4 on a normal hosttrue

The middle row is the trap. default_ipv4 on a host with no default route is {} — defined, empty, and ansible_facts.default_ipv4.address raises an undefined error on the next line.

Read-only / Safetesting for the thing you are about to read
# Fragile: passes on a host where default_ipv4 is an empty dict,
# then fails on the next line.
- name: Register the address
ansible.builtin.debug:
  msg: "{{ ansible_facts.default_ipv4.address }}"
when: ansible_facts.default_ipv4 is defined

# Holds up: tests the exact value being read.
- name: Register the address
ansible.builtin.debug:
  msg: "{{ ansible_facts.default_ipv4.address }}"
when: ansible_facts.default_ipv4.address is defined

# Better still where a fallback is meaningful: no conditional at all.
- name: Register the address
ansible.builtin.debug:
  msg: "{{ ansible_facts.default_ipv4.address | default('none') }}"

The default() filter is the right answer more often than a when:, because it puts the fallback next to the value instead of in a separate line that a later edit can get out of step with.

Facts with a shape that changes

Some facts are not a value but a structure, and the structure differs between hosts. ansible_facts.selinux is the standard example:

Read-only / Safea fact whose shape depends on the host
$ ansible localhost -m ansible.builtin.setup -a "filter=ansible_selinux,ansible_service_mgr,ansible_pkg_mgr,ansible_fips"
localhost | SUCCESS => {
  "ansible_facts": {
      "ansible_fips": false,
      "ansible_pkg_mgr": "apt",
      "ansible_selinux": {
          "status": "disabled"
      },
      "ansible_service_mgr": "systemd"
  },
  "changed": false
}

ansible_facts.selinux.mode does not exist on this host. It exists on a RHEL host with SELinux enabled. A play that reads it works perfectly across the RHEL half of a mixed fleet and fails on the Debian half — which is the worst kind of failure, because it passed review, it passed staging if staging was RHEL, and it fails on part of production.

The habit that catches this class: when you write a conditional on a fact, ask which host in your fleet does not have it. If you cannot name one, you have not looked at a wide enough sample of the fleet.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play reads ansible_facts.default_ipv4.address and is guarded by when: ansible_facts.default_ipv4 is defined. It still fails on some hosts. Why?

  2. Q2. You need the number of logical CPUs to size a worker pool. Which fact is correct?

  3. Q3. Which facts should be treated as inferred heuristics rather than direct readings? Select all that apply.

  4. Q4. A fact being absent from the result is reliable evidence that the host does not have the corresponding feature.

Passing score: 75%. Answers are checked in this browser.