AnsibleXIV · Facts and Registered VariablesFact gathering
Custom facts with facts.d
What you'll learn
- Place a custom fact so it appears under the ansible_local namespace
- Choose between a static INI, static JSON and executable fact file
- Explain why INI facts arrive as strings and what that breaks in 2.21
- Judge when a host-declared exception is better design than an inventory entry
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
Everything so far has been Ansible asking a host questions from a fixed list. Custom facts turn that around: the host gets to volunteer information that no fact module knows how to ask for.
The mechanism is small. The interesting part is deciding what belongs there, because the obvious uses are mostly bad ones and the good use is narrow and genuinely valuable.
The mechanism
Drop a file ending in .fact into a directory on the managed node.
The setup module reads that directory and puts the contents under
ansible_facts.ansible_local, keyed by filename without the extension.
The directory defaults to /etc/ansible/facts.d, and the fact_path
play keyword overrides it.
Two static files on a host:
[change_control]
frozen = true
ticket = CHG-004417
reason = pending storage migration{
"owner": "platform-team",
"tier": "gold"
}And what a play sees:
$ ansible-playbook local.ymlTASK [The ansible_local namespace] *********************************************
ok: [localhost] => {
"ansible_facts.ansible_local": {
"exception": {
"change_control": {
"frozen": "true",
"reason": "pending storage migration",
"ticket": "CHG-004417"
}
},
"inventory": {
"owner": "platform-team",
"tier": "gold"
}
}
}The play that produced it:
- name: Read host-declared facts
hosts: all
gather_facts: true
fact_path: /etc/ansible/facts.d
gather_subset:
- '!all'
- '!min'
- local
tasks:
- name: The ansible_local namespace
ansible.builtin.debug:
var: ansible_facts.ansible_localNote gather_subset: local. Custom facts are their own collector, so a
play that only needs them can skip everything else. That combination —
!all, !min, local — is a fleet-wide survey of host-declared state
that costs almost nothing.
$ ansible-playbook frozen.ymlTASK [Type of the INI fact] ****************************************************
ok: [localhost] => {
"msg": "frozen is a str with value true"
}
TASK [Branch on it directly] ***************************************************
[ERROR]: Task failed: A 'when' expression failed: Conditional result (True)
was derived from value of type 'str'. Conditionals must have a boolean result.
fatal: [localhost]: FAILED! => {"msg": "Task failed: A 'when' expression
failed: Conditional result (True) was derived from value of type 'str'.
Conditionals must have a boolean result."}This is a genuine improvement, and it is worth understanding why. On
older versions that conditional was silently true — because a non-empty
string is truthy — so a fact file saying frozen = false would have
enabled the change it was written to block. The failure mode was a
change freeze that did the opposite of what it said. Now it fails
loudly instead.
Two ways to make it correct:
# 1. Cast at the point of use. Works with INI facts.
- name: Skip when the host declares a freeze
ansible.builtin.debug:
msg: "would deploy"
when: not (ansible_facts.ansible_local.exception.change_control.frozen | bool)
# 2. Use JSON for the fact file. Then frozen is a real boolean
# and the conditional needs no filter at all.
- name: Skip when the host declares a freeze
ansible.builtin.debug:
msg: "would deploy"
when: not ansible_facts.ansible_local.exception.change_control.frozenThe second is better. A | bool sprinkled through a repository is a
maintenance tax and it is easy to forget on the one conditional that
matters. Write custom fact files as JSON. The types survive, and
Part XV lesson 1 shows how much of the conditional-logic pain in
Ansible comes from types that were lost somewhere upstream.
Static and executable fact files
A .fact file that is executable is run, and its stdout is parsed
as JSON or INI. A file that is not executable is read directly.
That distinction is the whole API, and it is decided by the execute bit
alone. A fact script that loses its execute bit does not error — the
setup module reads the script source, fails to parse it, and the fact
is missing or garbage.
#!/bin/sh
# /etc/ansible/facts.d/app.fact (mode 0755)
set -eu
VERSION=$(cat /opt/app/VERSION 2>/dev/null || echo unknown)
printf '{"version": "%s", "config_generation": %s}\n' \
"$VERSION" \
"$(cat /opt/app/generation 2>/dev/null || echo 0)"Executable facts are powerful and they are also the part of this mechanism that causes operational problems, so they come with rules.
They run on every gather. Every play, every host, every run. A fact script that takes two seconds adds two seconds per host to every run in the estate. Keep them to reading a file and printing.
They run as the connecting user, before become. A script needing
root to produce its answer will not get it. Design the answer to be
readable by the automation user, or the fact does not work.
A failure is silent. A script that exits non-zero, or prints anything that is not valid JSON or INI, produces a missing or malformed key rather than a task failure. Combine that with a conditional and you have a play that quietly does the wrong thing on the hosts where the script broke.
They are a place code hides. A shell script on 500 hosts, deployed by some other mechanism, whose output your automation branches on, is code you now maintain in two places with no shared review. If the answer can come from a file, use a static file.
The good use: a host that declares a documented exception
Here is the case that justifies the whole mechanism.
A fleet-wide patching run must skip one database host, because a storage migration is in progress and a reboot would be a data-loss event. You have three places to record that.
In the playbook. A when: inventory_hostname != 'db07' in the
patching role. Terrible: it is invisible to anyone reading the
inventory, it will still be there in a year, and it puts a specific
host’s name in shared code.
In the inventory. A host_vars/db07.yml with
patching_exempt: true. Good, and for most exceptions this is the
right answer — Part XIII’s layering rule says host_vars records
exceptions and nothing else, and this is exactly that.
On the host. A .fact file the host carries with it.
The third is right when the exception is a property the host knows about and the inventory would have to be told. The storage migration was started by a database engineer at the console. They can drop a fact file as part of the runbook step that starts the migration, and remove it as part of the step that finishes it. The exception has the same lifetime as the condition that caused it.
- name: Patch the fleet
hosts: all
gather_subset:
- '!all'
- '!min'
- local
- distribution
tasks:
- name: Report hosts that have declared a change freeze
ansible.builtin.debug:
msg: >-
SKIPPING {{ inventory_hostname }}:
{{ ansible_facts.ansible_local.exception.change_control.reason }}
(ticket {{ ansible_facts.ansible_local.exception.change_control.ticket }})
when: ansible_facts.ansible_local.exception.change_control.frozen
| default(false) | bool
- name: Apply pending updates
ansible.builtin.package:
name: '*'
state: latest
when: not (ansible_facts.ansible_local.exception.change_control.frozen
| default(false) | bool)Two details make this work rather than merely run.
| default(false) handles the overwhelming majority of hosts, which
have no such fact file at all. Without it the play fails on every
unexempted host — the opposite of intended.
The reason and ticket are printed, not just consulted. A skip that leaves no trace is indistinguishable from a bug. A skip that prints the ticket number is an auditable decision, and it also means that when the storage migration finished three weeks ago and nobody removed the file, the ticket number in the run output is what makes someone notice.
Knowledge check
Knowledge check · 4 questions
Q1. What determines whether a .fact file is executed or read as data?
Q2. An INI fact file contains frozen = true, and a play uses when: ansible_facts.ansible_local.app.frozen. What happens on ansible-core 2.21?
Q3. Which of these are sound reasons to prefer host_vars over a facts.d file for recording an exception? Select all that apply.
Q4. An executable fact script runs as the connecting user, before any become directive in the play applies.
Passing score: 75%. Answers are checked in this browser.