AnsibleXIV · Facts and Registered VariablesFact gathering
Fact caching and stale truth
What you'll learn
- Configure a persistent fact cache and confirm it is being used
- Explain what gathering: smart changes about when setup runs
- Recognise a run that made a decision on stale facts
- Choose a cache timeout that matches how fast the facts you branch on change
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
Lesson 1 measured what gathering costs and the number was uncomfortable:
94% of a fifty-host run, before any work. Restricting gather_subset
halved it. Fact caching removes it almost entirely.
It also introduces a hazard that nothing else in Ansible has. Every other failure mode in this course announces itself — a task fails, a host is unreachable, a conditional errors. A stale fact produces a green run that configured the wrong thing, and the run output contains no evidence that anything went wrong.
This lesson sets up caching, then breaks it deliberately so you can see what the failure looks like from the operator’s side.
Caching is always on; the question is which plugin
Upstream puts it precisely: fact caching is always active, and the
default plugin is memory, which “only caches the data for the current
execution of Ansible”. So the default is a cache that is discarded when
the process exits — which is why you pay for gathering on every run.
$ ansible-config dump | grep -E 'CACHE_PLUGIN|GATHERING'CACHE_PLUGIN(default) = memory
CACHE_PLUGIN_CONNECTION(default) = None
CACHE_PLUGIN_PREFIX(default) = ansible_facts
CACHE_PLUGIN_TIMEOUT(default) = 86400
DEFAULT_GATHERING(default) = implicitSwitching to a persistent plugin is three settings:
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /var/cache/ansible/facts
fact_caching_timeout = 7200gathering is the keyword that makes the cache useful, and its three
values are worth knowing exactly:
| Value | Behaviour |
|---|---|
implicit | The default. Gather in every play unless gather_facts: false. The cache is written but never consulted for skipping. |
explicit | Never gather unless a play sets gather_facts: true. |
smart | Gather once per host per run, and skip gathering entirely for a host already in the cache. |
smart is the one that pays for the cache. Without it, you write cache
entries you never read.
Watching it work
Run the same play twice with a jsonfile cache and gathering: smart:
$ ansible-playbook cache.ymlPLAY [cached] ******************************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Show distribution] *******************************************************
ok: [localhost] => {
"msg": "Ubuntu 26.04"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0
elapsed 0.58$ ansible-playbook cache.ymlPLAY [cached] ******************************************************************
TASK [Show distribution] *******************************************************
ok: [localhost] => {
"msg": "Ubuntu 26.04"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0
elapsed 0.30Look at what changed in the output: the Gathering Facts task is
gone. Not skipped, not ok — absent. ok drops from 2 to 1.
That absence is the single most useful diagnostic signal in this
lesson. If you are reading a run and there is no Gathering Facts line,
every fact that play used came from disk, and the host was not asked
anything.
Breaking it deliberately
Here is the scenario the break/fix for this part is built on. A play
chooses a package manager from ansible_facts.os_family. The cache
holds facts written before the host was rebuilt onto a different
distribution.
Simulated by editing the cache entry directly and re-running:
$ ansible-playbook cache2.ymlPLAY [cached] ******************************************************************
TASK [Choose a package manager from the cached fact] ****************************
ok: [localhost] => {
"msg": "Rocky / RedHat -> would use dnf"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0ok=1, failed=0, unreachable=0. By every signal Ansible offers,
that run succeeded. It selected dnf for a Debian host.
--flush-cache discards the cache for the run and forces gathering:
$ ansible-playbook --flush-cache cache2.ymlPLAY [cached] ******************************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Choose a package manager from the cached fact] ****************************
ok: [localhost] => {
"msg": "Ubuntu / Debian -> would use apt"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0Two runs of the same playbook, minutes apart, on an unchanged host,
producing opposite decisions. Nothing in either recap distinguishes
them. The only visible difference is the presence of the
Gathering Facts task.
Choosing a timeout
fact_caching_timeout defaults to 86400 seconds — 24 hours. That is a
long time in an estate that rebuilds hosts.
The question to ask is not “how long do I want to cache” but “how fast does the fastest-changing fact I branch on change, and what happens if I act on the old value?”
| What you branch on | Reasonable timeout | Why |
|---|---|---|
os_family, distribution | Hours to a day | Changes only on rebuild or major upgrade — but that is exactly when it matters |
default_ipv4.address | Minutes to an hour | Changes on re-IP, failover, DHCP lease |
mounts, devices | Do not cache decisions on these | A play that checks free space from a cached fact is not checking free space |
memtotal_mb, processor_vcpus | Hours | Changes on resize, which is a planned event you can hook |
The row that matters most is the third. Anything you are checking as
a precondition should not come from cache. A guard that refuses to
deploy when a filesystem is over 90% full, reading a cached mounts
fact, is a guard that tells you about disk usage from yesterday. That
is worse than no guard, because it reads as protection.
Where a play needs both — cached identity facts for speed, live state facts for a guard — gather explicitly for the live part:
- name: Deploy with a disk-space precondition
hosts: appservers
# Identity facts arrive from cache under gathering: smart.
tasks:
- name: Re-read the mount table, now, from the host
ansible.builtin.setup:
gather_subset:
- '!all'
- '!min'
- hardware
- name: Refuse to deploy onto a full filesystem
ansible.builtin.assert:
that:
- item.size_available > 1073741824
fail_msg: "{{ item.mount }} has under 1 GiB free"
loop: "{{ ansible_facts.mounts }}"
loop_control:
label: "{{ item.mount }}"Note the shape: setup as an explicit task takes gather_subset
as a module argument. As a play keyword it configures the implicit
gathering task; as a module argument it configures this call. Same
name, two levels, and the task form is the one that lets you refresh
part of the fact set mid-play.
set_fact and cacheable
set_fact normally creates a host variable that lives for the run.
With cacheable: true it also writes a fact that the cache plugin will
persist.
Upstream is unusually careful about what this does, and it is worth
quoting because the behaviour surprises people: it “actually creates 2
copies of the variable, a normal set_fact host variable with high
precedence and a lower ansible_fact one that is available for
persistence via the facts cache plugin”.
Three consequences:
Precedence changes between runs. In the run that sets it, the value
sits at the set_fact level — entry 19 of the precedence ladder from
Part XIII, above almost everything. In the next run it arrives as a
cached fact, at entry 11, below play vars, vars_files, role vars and
more. A value that overrode your group_vars today is overridden by it
tomorrow.
cacheable: true does not enable caching. It means the value will
work with a cache if one is already configured. With the default
memory plugin it persists for exactly as long as an ordinary
set_fact would.
meta: clear_facts removes only one of the two copies. It clears
the ansible_fact copy and leaves the host variable, which is a
genuinely confusing state to debug.
The honest guidance: cacheable: true is for genuinely derived,
genuinely expensive, genuinely stable values — the result of an
inventory API call, a computed cluster topology. For anything else the
precedence shift between run one and run two is a bug waiting for a
quiet afternoon.
Knowledge check
Knowledge check · 4 questions
Q1. A run reads facts and branches on them, and its output contains no Gathering Facts task at all. What does that tell you?
Q2. A play must refuse to deploy when a filesystem is over 90% full. Facts are cached with a two-hour timeout. What is the correct design?
Q3. Which statements about set_fact with cacheable: true are accurate? Select all that apply.
Q4. Reading the jsonfile cache file directly is the recommended way to check what facts Ansible currently holds for a host.
Passing score: 75%. Answers are checked in this browser.