AnsibleXIV · Facts and Registered VariablesFact gathering
Which facts you can trust
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
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:
$ 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.
$ 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.
$ 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:
| State | Example | is defined |
|---|---|---|
| Never collected | mounts when gather_subset excluded hardware | false |
| Collected, no data | default_ipv4 on a host with no default route | true |
| Collected, has data | default_ipv4 on a normal host | true |
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.
# 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:
$ 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
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?
Q2. You need the number of logical CPUs to size a worker pool. Which fact is correct?
Q3. Which facts should be treated as inferred heuristics rather than direct readings? Select all that apply.
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.