Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesFact gathering

What fact gathering actually costs

Intermediate⏱ ~24 minansible-playbook

What you'll learn

  • Describe the implicit setup task and where it sits in play execution
  • Measure what gathering costs on your own inventory rather than guessing
  • Restrict gathering with gather_subset and its negation form
  • Decide when gather_facts false is correct and what it breaks

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.

Every play you have written so far started with a task you did not write.

PLAY [configure web tier] ******************************************************

TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]

That task is ansible.builtin.setup, and Ansible inserts it because gather_facts defaults to true. It runs against every host in the play, before your first real task, and it does the full work of a module: build the payload, open a connection, copy, execute under the target’s Python, parse JSON back.

Most people meet this as a line of output they scroll past. On a fifty-host inventory it is a second or two and nobody notices. On two thousand hosts it is frequently the largest single item in the run, and it happens before you have changed anything at all.

This lesson measures it rather than asserting it, because “gathering is slow” is the kind of claim that gets repeated into folklore and then used to justify gather_facts: false everywhere, which breaks things in ways the next lessons have to clean up.

What the implicit task is

setup is an ordinary module. It runs on the target, collects information about the system, and returns it under the ansible_facts key. Ansible then merges that dictionary into the host’s variables, which is why ansible_facts.distribution is available to every subsequent task without you doing anything.

Three play keywords control it, and all three are genuine play-level keywords rather than module arguments:

KeywordTypeDefaultWhat it does
gather_factsbooltrueWhether the implicit setup task runs at all
gather_subsetlistallWhich categories of facts to collect
gather_timeoutint10Per-subset timeout in seconds
fact_pathstring/etc/ansible/facts.dWhere custom local facts are read from
Read-only / Safethe keywords are real, and this is how to check
$ ansible-doc -t keyword gather_facts gather_subset gather_timeout
gather_facts:
applies_to:
- Play
description: A boolean that controls if the play will automatically run the 'setup'
  task to gather facts for the hosts.
type: bool
gather_subset:
applies_to:
- Play
description: Allows you to pass subset options to the fact gathering plugin controlled
  by 'gather_facts'.
type: list
gather_timeout:
applies_to:
- Play
description: Allows you to set the timeout for the fact gathering plugin controlled
  by 'gather_facts'.
type: int

Note applies_to: Play. These are not task keywords. You cannot put gather_subset on an individual task — to gather a different subset mid-play you call the setup module explicitly as a task, which is a different thing and is covered in lesson 5.

The measurement

Here is the experiment, run on ansible-core 2.21.3. Fifty hosts, forks: 5, a play whose only real task is a debug that does nothing. The connection is local, so there is zero network latency — this is the floor, not a typical case.

Read-only / Safethree identical plays, three gathering settings
$ for p in gather_all gather_min gather_none; do /usr/bin/time -f '%e s' ansible-playbook -i bench.ini -f 5 $p.yml; done
--- gather_all      (gather_facts: true)
7.65 s
7.20 s
--- gather_min      (gather_subset: ['!all'])
3.50 s
3.19 s
--- gather_none     (gather_facts: false)
0.40 s
0.42 s

Read that carefully. The play that does nothing takes 0.4 seconds. Add full fact gathering and it takes 7.2 seconds. Gathering is 94% of the run.

The minimal subset — everything except the expensive collectors — costs 3.2 seconds, so restricting the subset saved more than half the gathering time without disabling it.

Where the time goes

The subsets are not equal. Counting the facts each one returns shows where the weight sits:

Read-only / Safefact count by subset
$ for s in all '!all' '!all,!min' network hardware; do ansible localhost -m ansible.builtin.setup -a "gather_subset=$s"; done
all         -> 121 keys
!all        ->  55 keys
!all,!min   ->   2 keys
network     ->  76 keys
hardware    ->  90 keys

Full gathering returns 121 top-level facts. The min subset — what you get from !all — returns 55, and covers essentially everything a typical play branches on: distribution, kernel, hostname, service manager, package manager, Python, user identity, SSH host keys.

The remaining 66 facts are the expensive ones. hardware enumerates block devices, mounts and memory. network enumerates every interface and address. Those two collectors are where the seconds are, and they are also the two that most plays never read.

Verified against 2.21.3:

Read-only / Safeexactly one collector
$ 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",
      "gather_subset": [
          "!all",
          "!min",
          "distribution"
      ],
      "module_setup": true
  },
  "changed": false
}

Writing it in a play

Restricting the subset is a two-line change at play level:

Read-only / Safesite.yml
- name: Deploy the agent
hosts: appservers
gather_subset:
  - '!all'
  - '!min'
  - distribution
  - pkg_mgr
tasks:
  - name: Install the agent package
    ansible.builtin.package:
      name: monitoring-agent
      state: present

Two habits make this maintainable rather than a micro-optimisation someone reverts in six months:

Quote the negations. !all starts with ! and YAML will not parse it bare in some contexts. Always quote subset entries that begin with !. This is the same YAML rule covered in the YAML part, and it bites here more often than anywhere else.

Write down which facts the play needs. A comment naming the facts the play reads turns the subset list from a magic incantation into something a reviewer can check. When someone later adds a task that reads ansible_facts.mounts, the subset list is where the bug will be, and a comment is what makes that obvious.

gather_timeout and the host that hangs

gather_timeout defaults to 10 seconds and applies per fact collector, not to the gathering task as a whole.

That default is usually right and occasionally catastrophic. The classic case is hardware on a host with a hung NFS mount: the collector walks the mount table, stat() blocks on the dead mount, and the collector burns its full ten seconds before giving up. Multiply by the number of affected hosts, divide by forks, and a routine run becomes a long one.

Raising gather_timeout is almost always the wrong fix. It makes the run slower and hides the real problem, which is a broken mount. The right responses, in order of preference:

  1. Fix the host. A hung mount is an incident, not a gathering setting.
  2. Exclude the collector you do not need — '!hardware' — so the play stops touching the broken thing at all.
  3. Raise the timeout only if you genuinely need the facts from a host that is legitimately slow to answer.

When gather_facts: false is the right call

Disabling gathering entirely is correct more often than its reputation suggests, and wrong in one specific way that costs people an afternoon.

Correct when the play reads no facts. A play that restarts a service by name, or copies a file to a fixed path, or runs a health check against a URL, reads nothing the setup module produces. Gathering for it is pure cost. This is most of what an operational runbook play does.

Correct on a host with no Python. setup needs a Python on the target. Bootstrap plays that install Python with raw must disable gathering, because the gathering task would fail before the bootstrap task ran. This is covered in the interpreter part; the gathering consequence belongs here.

Correct when facts come from cache. If a fact cache is configured and populated, gathering: smart will skip the gathering task for hosts it already has facts for. That is lesson 5, and it is the setting that lets you keep facts and stop paying for them on every run.

Wrong when a module gathers on your behalf. This is the one that catches people. Upstream documents it plainly for ansible.builtin.package: it selects the underlying package manager from facts, and “if ansible.builtin.setup was not yet run, ansible.builtin.package will run it”.

So a play with gather_facts: false whose first task is package gathers facts anyway — just later, and without any of the subset restrictions you set at play level, and with the cost hidden inside a task that appears to be doing something else. The run is not faster. It is the same cost, harder to see.

A decision you can apply

For any play, in order:

  1. Does it read facts at all? Grep the play and its roles for ansible_facts and ansible_. If nothing, gather_facts: false and move on — but check for a package, service or setup task that will gather implicitly.
  2. Which facts does it read? Map each to a collector. Distribution branching needs distribution. Choosing a package manager needs pkg_mgr. Interface addresses need network. Disk checks need hardware.
  3. Set the subset explicitly, with a comment naming why.
  4. Measure, on your own inventory, before and after. If the saving is a tenth of a second, put the default back and spend the attention elsewhere.

Step 4 matters as much as the rest. Restricting the subset on a twelve-host inventory is a change with a cost — a future reader has to understand it — and no benefit. Fleet-scale reasoning applied at small scale is just complexity.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play sets gather_facts: false and its first task uses ansible.builtin.package to install a package. What actually happens to fact gathering?

  2. Q2. What does gather_subset: ['!all'] collect?

  3. Q3. A fleet run is slow and gathering is suspected. Which of these are sound next steps? Select all that apply.

  4. Q4. gather_subset is a play-level keyword and cannot be set on an individual task.

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