Facts: What the Machine Tells You About Itself

Scenario: A play must branch on the operating system family, but it has gather_facts: false, so it fails with "'ansible_facts' is undefined".

New words, in plain English

Before the first task of a play, Ansible normally runs a hidden setup task that interrogates the machine and returns several hundred values: ansible_facts['os_family'], ansible_facts['distribution'], ansible_facts['memtotal_mb'], network interfaces, mounted filesystems, and much more.

This is genuinely expensive. Full gathering means real work on the target and a full connection round-trip, per host, per play. On a fleet of hundreds, with several plays, it can dominate the run time - and most plays read no facts at all.

So there are two symmetrical mistakes. Gathering when you do not need to wastes time on every run. Not gathering when you do need to produces an undefined-variable failure that looks mysterious until you spot the gather_facts: false.

The middle ground is gather_subset. gather_subset: ['!all', 'min'] collects the cheap identity facts (distribution, OS family, kernel, hostname) and skips hardware and network enumeration entirely. That covers the large majority of real needs at a fraction of the cost.

Note that magic variables are not facts. inventory_hostname, group_names, groups and hostvars come from the inventory and are always available, even with gathering switched off entirely.

Analogy: Fact gathering is a full medical examination before every appointment. Sometimes you need the blood work. Usually the receptionist just needs your name and date of birth - and gather_subset is the short form.

A worked example

# Cheap: this play reads no facts at all
- name: Drop the maintenance banner
  hosts: production
  gather_facts: false
  tasks:
    - ansible.builtin.copy:
        dest: "/etc/motd.d/maintenance"
        content: "maintenance 02:00-04:00 UTC\n"

# Gathers, but only the identity facts it actually needs
- name: Build the platform report
  hosts: production
  gather_facts: true
  gather_subset: ['!all', 'min']
  tasks:
    - ansible.builtin.copy:
        dest: /etc/facts-report.txt
        content: |
          host={{ inventory_hostname }}
          os_family={{ ansible_facts['os_family'] }}
          distribution={{ ansible_facts['distribution'] }}

For a large estate, fact caching turns the cost into a once-per-interval expense rather than a per-run one. Setting fact_caching = jsonfile, fact_caching_connection = /tmp/ansible_facts and fact_caching_timeout = 3600 in ansible.cfg stores gathered facts on disk and reuses them until they expire. Redis and Memcached backends exist for shared CI runners.

Caching also enables something otherwise awkward: reading another host's facts with hostvars['dbprod']['ansible_facts']['default_ipv4']['address'] even in a play that does not target dbprod - because its facts are already in the cache from a previous play or run.

To see what is actually available, run ansible web01 -m setup and read it. It is a long output, and skimming it once is worth more than any summary.

Tip: ansible <host> -m setup -a 'filter=ansible_distribution*' narrows the firehose to the handful of facts you actually care about.
Goal: Put this to work in the ansible-facts-performance lab. Open /labs/ansible, pick ansible-facts-performance, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.