Chapter 5: Variables & Facts
Topic 28

Facts and Gathering

ConceptFacts

Facts are the properties Ansible discovers about a managed node — its OS, IP addresses, memory, mounted disks, CPU count, distribution version — by interrogating the box live at the start of a run. This is the heart of the spine the whole course leans on: there is no state file, so Ansible doesn't remember what web1 looks like; it asks the machine every run, and the machine's answer is the truth.

Facts are gathered by an implicit first task on every play and land in ansible_facts, the dictionary your conditionals and templates read. Larkspur's nginx config templates the host's real IP and branches on its OS family entirely from gathered facts — values that are correct today because they were read off the box today, with nothing cached in between.

Discovered Live, Every Run

Fact gathering runs the setup module against each host as the first task of a play, before any of your tasks execute. Nothing is cached between runs by default, so a host that grew RAM, swapped a disk, or changed IP since yesterday reports the new value today. The node is queried, not recalled — which is precisely why no state file is needed. The machine carries its own truth, and the run reads it fresh.

How a fact lands in ansible_facts
play starts
gather_facts runs setup
ansible_facts populated
tasks read them

This is the cleanest expression of the stateless design. A provisioning tool consults a stored record of what it believes the world contains; Ansible consults the world itself. A reboot that reassigned a DHCP address shows up in the very next run's facts with no refresh step, no terraform refresh equivalent, because there was never a stored copy to fall out of sync in the first place.

The ansible_facts Namespace

Gathered facts populate the ansible_facts dictionary. ansible_facts['os_family'] reports Debian on Larkspur's Ubuntu boxes; ansible_facts['default_ipv4']['address'] gives the primary IP; ansible_facts['memtotal_mb'] the memory in megabytes; ansible_facts['distribution_version'] the exact release. Templates and when: clauses read these keys to branch on what the host actually is — install the Debian package set when os_family is Debian, tune workers to the CPU count, template the live IP into a config.

A play that branches and templates from gathered facts — no facts defined anywhere, all read live
- hosts: web
  tasks:
    - name: Install base packages on Debian-family hosts
      ansible.builtin.apt:
        name: "{{ larkspur_web_base_packages }}"
        state: present
      when: ansible_facts['os_family'] == 'Debian'

    - name: Show the IP this run gathered
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is {{ ansible_facts['default_ipv4']['address'] }}"

The when: reads a fact to decide whether the task runs at all, and the debug templates a fact into a message. Neither value was set by you — both were discovered by the implicit gather before this play's first task, which is what makes the branch reflect the host as it is right now.

The Legacy Top-Level Names

Older playbooks reference facts as bare top-level variables — ansible_os_family, ansible_default_ipv4 — injected alongside the ansible_facts dictionary. This injection is on by default but controllable through configuration, and a codebase that turns it off leaves the bare names undefined while ansible_facts['os_family'] keeps working. For new work, prefer the namespaced form: it survives injection being disabled and makes it obvious a value is a gathered fact rather than a variable you set.

The two forms hold the same data, so you will see both in the wild. The trap is mixing a codebase that disabled injection with a playbook that reaches for ansible_os_family — the reference resolves to undefined, the when: evaluates wrong, and the cause is a configuration setting three files away. Standardizing on ansible_facts['...'] sidesteps that entirely.

gather_facts and the Cost of Gathering

Gathering is on by default and adds a real round-trip per host — often the single slowest step on an otherwise fast play, because setup collects a great deal of data the play may never read. gather_facts: false at the play level skips it entirely, which is the right call when no task in the play references a fact. On a large fleet that one setting shaves seconds off every host, and on a 200-host play it is the difference between a fast run and a slow one.

The cost is only worth paying when a fact is actually consumed. A play that just restarts a service or pushes a static file references nothing the gather produces, so the gather is pure overhead — every host pays for a full setup sweep that no task reads. Turning it off there is free speed; the only risk is a later edit adding a fact reference and forgetting the gather is disabled, which then fails undefined.

gather_subset — Pay Only for the Facts You Use

Between full gathering and none sits gather_subset, which narrows the sweep to the categories you need. gather_subset: ['!all', '!min', 'network'] gathers only network facts — enough for default_ipv4 — instead of the full hardware, virtual, and platform sweep, cutting the per-host cost when you read just the IP. gather_subset: min is the lean baseline that still gives you the host's identity and OS without the expensive hardware probe.

The subset syntax composes with ! to exclude: ['!all', '!min', 'network'] starts from nothing and adds back only the network category. This matters most on plays that read one or two facts off a wide fleet — you stop paying for memory, mounts, and CPU enumeration you will never reference, and the saved round-trip time multiplies across every host.

setup as an Ad-hoc Probe

Before you bake a fact key into a template, confirm its exact name with the setup module as an ad-hoc command. ansible web1.larkspur.io -m setup dumps every fact the host exposes, and ansible web1.larkspur.io -m setup -a 'filter=ansible_distribution*' narrows the dump to the keys matching a pattern. This is how you discover that the key is os_family and not os, before a typo'd reference fails undefined deep in a run.

Guessing fact names is the slow way; probing is the fast way. The full setup output is long, so the filter= argument is how you find the one key you care about — distribution, default route, memory — and copy its exact spelling into the template. Discovering the name first, then referencing it, turns "undefined variable" surprises into a thirty-second lookup.

Common Mistakes
  • Referencing ansible_facts['default_ipv4']['address'] in a play that set gather_facts: false — facts were never gathered, the key is undefined, and the task fails on a host you can plainly ping.
  • Leaving full gathering on for a 200-host play that uses zero facts — every host pays the full setup round-trip for nothing, and the run is needlessly slow.
  • Guessing a fact's key name — ansible_os instead of ansible_facts['os_family'] — and hitting "undefined variable"; dump the real names with -m setup first.
  • Assuming a fact reflects yesterday's state because state-file habits expect a stored value — facts are gathered fresh each run, so a new IP after a reboot changes in the play immediately, with no refresh step.
  • Reading a fact under the legacy top-level name in a codebase that disabled fact injection — the bare ansible_os_family is undefined while ansible_facts['os_family'] still works.
Best Practices
  • Reference facts through the ansible_facts['...'] namespace rather than the legacy top-level ansible_* names, so playbooks survive fact injection being turned off.
  • Set gather_facts: false on plays that reference no facts, and use gather_subset to limit gathering to the categories you actually read on plays that do.
  • Discover exact fact keys with ansible <host> -m setup -a 'filter=<pattern>' before referencing them in templates or conditionals, instead of guessing names that fail at run time.
  • Treat facts as live truth, not a cache — a value reflects the host as it is this run, which is exactly why no state file is needed.
  • When you disable gathering for speed, scan the play for any later-added fact reference before each run, so a new ansible_facts read doesn't fail undefined.
Comparable tools Puppet Facter — the direct analog, discovers node facts the same way Chef Ohai plays the identical role Salt grains are its facts Terraform no equivalent — it reads state and provider APIs, not live host introspection

Knowledge Check

When does fact gathering run relative to the tasks you wrote in a play?

  • As an implicit first task — the setup module runs against each host before any of your tasks
  • As an implicit last task, so the gathered facts reflect the final state the play left the host in
  • Once while the inventory is parsed, then reused unchanged for every play across the entire run
  • Lazily, only the first time a task actually references ansible_facts, and not at all otherwise

What does gather_facts: false save, and what does it break?

  • It saves the per-host setup round-trip but breaks any task that references a fact, which is now undefined
  • It saves disk on the control node and breaks nothing, since each fact is recomputed on demand when read
  • It saves nothing measurable and only disables the older legacy top-level fact variable names
  • It saves the SSH connection itself, so the entire play then runs locally on the control node

A host changed its IP after a reboot last night. What does today's run see, and why?

  • The new IP — facts are gathered live each run off the box, so there is no stored value to refresh
  • The old IP until you run an explicit refresh step, because Ansible caches the last gathered value by default
  • A hard error, because the stored state file no longer matches the host's real current address
  • Whichever IP was last committed to host_vars, since gathered facts defer to inventory

What does gather_subset: ['!all', '!min', 'network'] trade?

  • It gathers only network facts instead of the full sweep, cutting per-host cost but leaving hardware and virtual facts ungathered
  • It gathers everything except network facts, trading away the host's IP and interface data in exchange for faster hardware probes
  • It caches just the network facts to local disk so the very next run can skip gathering entirely
  • It disables gathering over the network and runs the setup probe only over a local socket

You got correct