Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesFact gathering

Fact caching and stale truth

Advanced⏱ ~26 minansible-playbookansible-config

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

Not yet marked complete on this device.

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.

Read-only / Safethe caching settings and their defaults
$ 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) = implicit

Switching to a persistent plugin is three settings:

Configuration changeansible.cfg
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /var/cache/ansible/facts
fact_caching_timeout = 7200

gathering is the keyword that makes the cache useful, and its three values are worth knowing exactly:

ValueBehaviour
implicitThe default. Gather in every play unless gather_facts: false. The cache is written but never consulted for skipping.
explicitNever gather unless a play sets gather_facts: true.
smartGather 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:

Read-only / Saferun one: gathers and writes the cache
$ ansible-playbook cache.yml
PLAY [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
Read-only / Saferun two: no gathering task at all
$ ansible-playbook cache.yml
PLAY [cached] ******************************************************************

TASK [Show distribution] *******************************************************
ok: [localhost] => {
  "msg": "Ubuntu 26.04"
}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0

elapsed 0.30

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

Read-only / Safea completely green run that is completely wrong
$ ansible-playbook cache2.yml
PLAY [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=0

ok=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:

Read-only / Safethe same play with --flush-cache
$ ansible-playbook --flush-cache cache2.yml
PLAY [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=0

Two 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 onReasonable timeoutWhy
os_family, distributionHours to a dayChanges only on rebuild or major upgrade — but that is exactly when it matters
default_ipv4.addressMinutes to an hourChanges on re-IP, failover, DHCP lease
mounts, devicesDo not cache decisions on theseA play that checks free space from a cached fact is not checking free space
memtotal_mb, processor_vcpusHoursChanges 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:

Read-only / Safelive facts for the thing that must be live
- 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

  1. Q1. A run reads facts and branches on them, and its output contains no Gathering Facts task at all. What does that tell you?

  2. 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?

  3. Q3. Which statements about set_fact with cacheable: true are accurate? Select all that apply.

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