SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Ansible facts: when to turn gather_facts off

Learn what Ansible facts cost, when gather_facts: false is the right call, and how gather_subset and JSON fact caching cut the wait on every run.

What Ansible facts are, and when to turn gather_facts off

Ansible facts are the details Ansible collects about a host before a play runs: the OS (operating system) distribution, network interfaces, memory, disks and the Python version. The setup module collects them. It runs as a hidden first task called Gathering Facts in every play, unless you turn it off. Turn it off with gather_facts: false in two cases. The first is a host with no Python yet. The second is a play that reads no facts and runs across many hosts.

There is also a middle ground. gather_subset collects only the facts a play needs, and a fact cache keeps facts between runs. This guide covers both, plus local facts that you define yourself.

Every command here runs against the VPS itself, as localhost over a local connection. You need one Ubuntu 24.04 server and no second machine. If you have never written a playbook, start with your first Ansible playbook on a VPS and come back.

Install Ansible from Ubuntu's own package

sudo apt update
sudo apt install -y ansible
ansible --version

The first line of ansible --version names the ansible-core release. The lines after it show the config file in use and the Python version. Keep that first line in mind, because it decides some defaults later in this guide. The ansible package comes from Ubuntu's universe repository. It pulls in ansible-core and a bundle of collections, including ansible.posix, which this guide uses once for timing.

Make a project directory in your home directory. Do not use /tmp or any other world-writable directory. Ansible refuses to read an ansible.cfg from such a place, and it prints a warning like this:

[WARNING]: Ansible is being run in a world writable directory (/tmp/facts-lab), ignoring it as an ansible.cfg source.

Create the project directory and its first two files:

mkdir -p ~/facts-lab && cd ~/facts-lab
printf '%s\n' 'localhost ansible_connection=local ansible_python_interpreter=/usr/bin/python3' > hosts.ini
cat > ansible.cfg <<'EOF'
[defaults]
inventory = hosts.ini
EOF
ansible-inventory --list

ansible-inventory --list should show localhost in the ungrouped group. ansible_connection=local tells Ansible to run modules on this machine directly, instead of over SSH (secure shell). ansible_python_interpreter names the Python that runs each module, so Ansible does not have to search for one. The inventory format itself is covered in how the Ansible inventory file works.

How do you see every fact Ansible gathers?

Run the setup module on its own, as an ad-hoc command:

ansible localhost -m ansible.builtin.setup | less

The output is one JSON (JavaScript Object Notation) object. Every fact sits under the ansible_facts key, and every key carries an ansible_ prefix, such as ansible_distribution or ansible_memtotal_mb. The full list is long. To narrow it, pass the filter option, which takes shell-style wildcard patterns:

ansible localhost -m ansible.builtin.setup -a 'filter=ansible_distribution*'
ansible localhost -m ansible.builtin.setup -a 'filter=ansible_*_mb'

The first command returns only the distribution facts. The second returns the memory facts, which are counted in megabytes. Read through a few. You will reach for some of them in templates and conditions.

Read facts in a playbook with ansible_facts

Inside a playbook, the prefix goes away. The fact printed as ansible_distribution is read as ansible_facts['distribution']. Save this as facts.yml:

- name: Read a few facts
  hosts: localhost
  connection: local
  gather_facts: true
  tasks:
    - name: Check that the facts this play needs exist
      ansible.builtin.assert:
        that:
          - ansible_facts['distribution'] is defined
          - ansible_facts['os_family'] is defined
          - ansible_facts['memtotal_mb'] is defined
          - ansible_facts['interfaces'] is defined

    - name: Count the top-level facts
      ansible.builtin.debug:
        msg: "{{ ansible_facts | length }} top-level facts gathered"
ansible-playbook facts.yml

The run shows TASK [Gathering Facts] before your own tasks. That task is the hidden setup call. The assert task should end with All assertions passed. The second task prints how many top-level facts this host returned. Note that number. It is your baseline, and the gather_subset section compares against it.

Use the same bracket form in conditions and templates, for example when: ansible_facts['os_family'] == 'Debian'. Fact lookups are most useful inside Jinja2 templates, which Ansible templates and handlers covers. Facts are gathered in check mode too, because setup changes nothing on the host. That means a dry run with --check sees the same facts as a real run.

ansible_* variables or ansible_facts: what your version says

Older playbooks read ansible_distribution directly, with no ansible_facts in front. That works because of a setting called INJECT_FACTS_AS_VARS. When it is on, Ansible also copies each fact into a top-level variable with the ansible_ prefix. Whether your release still turns it on by default, and whether it warns about it, depends on the ansible-core version. Ask the copy you installed:

ansible --version | head -n 1
ansible-config dump | grep INJECT_FACTS_AS_VARS
ansible-config list | grep -A 25 '^INJECT_FACTS_AS_VARS:'

ansible-config dump prints the current value, with its source in parentheses. A source of default means nothing in your ansible.cfg or your environment changed it. ansible-config list prints the full definition of the setting in YAML. If that entry holds a deprecated: block, the block names the version and the alternative. If there is no such block, your release does not deprecate the setting.

The same entry also has an env: line. Note the name there: the environment variable for this setting is ANSIBLE_INJECT_FACT_VARS, not the setting name with ANSIBLE_ in front. An environment variable with the wrong name is silently ignored.

Next, test what changes when injection is off. Save this as old-style.yml:

- name: An old-style fact lookup
  hosts: localhost
  connection: local
  tasks:
    - name: Read a fact as a top-level variable
      ansible.builtin.assert:
        that:
          - ansible_distribution is defined
ansible-playbook old-style.yml
ANSIBLE_INJECT_FACT_VARS=False ansible-config dump | grep INJECT_FACTS_AS_VARS
ANSIBLE_INJECT_FACT_VARS=False ansible-playbook old-style.yml

The first run uses your installed default. Read its output for a [DEPRECATION WARNING] line. If one appears, it says what will change. The ansible-config dump line confirms that the override took effect: it should show False, with the environment variable as its source. The second run asks for injection off, for that run only. Compare the two results instead of assuming one. If the second assert fails with Assertion failed, the playbook relies on injected variables and its lookups need to change. If both runs pass, your release still resolves ansible_distribution with the setting off, so this switch is not a reliable way to find old lookups on your install. Run facts.yml the same way and it still passes, because it only reads ansible_facts.

So write new playbooks with ansible_facts['...']. In old playbooks, search for fact names used without ansible_facts in front, and rewrite them in the bracket form. Two things are not affected. Inventory variables such as ansible_host and ansible_connection are not facts, so they keep working. On the ansible-core release in Ubuntu 24.04, ansible_local also stays available as a top-level variable with injection off.

What does gathering facts cost?

Gathering is one module run per host, per play. Ansible copies the setup module to the host, starts Python, and runs a set of collectors. Most collectors read files under /proc and /sys. Some run commands. The hardware collector, for example, reads every mount point. The setup module docs give gather_timeout a default of 10 seconds, which caps how long a slow collector may run.

Measure it on your own server. The profile_tasks callback from the ansible.posix collection prints the time each task took:

ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook facts.yml

The summary at the end lists Gathering Facts with its own time. On one local host the number is small. The cost that matters is the multiplication. With default settings, every play that gathers runs setup again on every host, even when an earlier play in the same run already did. Ansible also works through hosts in batches of forks, which is 5 by default. So a playbook with four plays over a hundred hosts runs setup four hundred times.

Reason one for gather_facts: false: a host with no Python

Every normal module is Python code that runs on the target host, and setup is no exception. A minimal image may ship without Python 3. On such a host the first task, Gathering Facts, fails before any of your tasks run. With the inventory above, the error output contains this line:

/bin/sh: 1: /usr/bin/python3: not found

Ansible adds The module failed to execute correctly, you probably need to set the interpreter. The interpreter setting is not the problem here. Python is simply absent. The fix is the order of operations. Turn gathering off, install Python with the raw module, then gather facts by hand. raw sends a plain shell command over the connection, so it needs no Python on the host. Save this as bootstrap.yml:

- name: Bootstrap Python, then gather facts
  hosts: localhost
  connection: local
  gather_facts: false
  become: true
  tasks:
    - name: Install python3 if it is missing
      ansible.builtin.raw: test -e /usr/bin/python3 && echo present || (apt-get update -qq && apt-get install -y -qq python3 && echo installed)
      register: py_bootstrap
      changed_when: "'installed' in py_bootstrap.stdout"

    - name: Gather facts now that Python exists
      ansible.builtin.setup:

    - name: Confirm the facts arrived
      ansible.builtin.assert:
        that:
          - ansible_facts['distribution'] is defined
ansible-playbook bootstrap.yml

become: true runs the tasks through sudo. Add -K to the command if sudo asks you for a password. On this VPS Python is already installed, so the first task reports ok. Point the same play at a new server's inventory group and it installs Python, then gathers. raw cannot tell whether it changed anything, so on its own it reports changed on every run. That is why the task registers its output and sets changed_when from the word the command printed.

Reason two: plays that never read a fact

Many plays never touch a fact. Restarting a service or pushing a config file usually needs no knowledge of the host. For those plays, gathering is time spent for nothing. Save this as nofacts.yml:

- name: A play that needs no facts
  hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Show the uptime
      ansible.builtin.command: uptime
      changed_when: false

Now time the two plays against each other:

time ansible-playbook facts.yml
time ansible-playbook nofacts.yml

Compare the real lines. The difference is roughly what one round of gathering costs on this host. Multiply it by your host count and your play count to see what it costs your fleet. If you run playbooks across a fleet from one control machine, as in managing many Linux servers from one place, this is often the cheapest speedup you have.

If most of your plays need no facts, flip the default instead of editing every play. Set gathering = explicit under [defaults] in ansible.cfg. After that, only plays that say gather_facts: true gather.

The risk is a task that needs a fact in a play with gathering off. Add a task that reads ansible_facts['distribution'] to nofacts.yml and the run fails with 'dict object' has no attribute 'distribution'. ansible_facts still exists in that play, but it is empty, because nothing filled it. There is one exception. Facts gathered by an earlier play in the same run stay in memory, so a later play with gathering off can still read them.

The middle ground: gather_subset and the setup filter

gather_subset decides which collectors run. !all drops everything except a minimal set, which the docs call min. You then add back the groups the play needs, such as network or hardware. Save this as subset.yml:

- name: Gather the minimal set plus network facts
  hosts: localhost
  connection: local
  gather_facts: true
  gather_subset:
    - '!all'
    - network
  tasks:
    - name: Check the facts this play uses
      ansible.builtin.assert:
        that:
          - ansible_facts['distribution'] is defined
          - ansible_facts['interfaces'] is defined

    - name: Count the top-level facts
      ansible.builtin.debug:
        msg: "{{ ansible_facts | length }} top-level facts gathered"
ansible-playbook subset.yml

Compare the count with your baseline from facts.yml. It is lower, because the hardware and virtualization collectors did not run. The distribution facts are still present, because distribution is part of min. To collect nothing at all, write !all and !min together.

The filter option works differently, and the difference matters. filter does not stop any collector from running. Each collector runs in full, and the filter then drops the facts that do not match. So filter makes the result smaller, and gather_subset makes the run faster. Use both when you want one group of facts and only a few keys from it:

    - name: Collect hardware facts and keep only memory
      ansible.builtin.setup:
        gather_subset:
          - '!all'
          - hardware
        filter:
          - 'ansible_memtotal_mb'
          - 'ansible_memfree_mb'

A play keyword like gather_subset belongs to the play, not to a role. A role that needs a fact cannot turn gathering on by itself, so it should call setup or assert that the fact exists. The split between what a play controls and what a role controls is laid out in Ansible playbook vs role.

Local facts in /etc/ansible/facts.d

Local facts are facts you write yourself. Put a file ending in .fact in /etc/ansible/facts.d on the managed host. A file without the execute bit is read as JSON or INI. A file with the execute bit is run as a program, and its output is read as JSON or INI. Create one of each:

sudo install -d -m 755 /etc/ansible/facts.d
printf '[general]\nrole=web\ntier=staging\n' | sudo tee /etc/ansible/facts.d/vps.fact
sudo chmod 644 /etc/ansible/facts.d/vps.fact

sudo tee /etc/ansible/facts.d/rootfs.fact > /dev/null <<'EOF'
#!/bin/sh
used=$(df --output=pcent / | tail -n 1 | tr -dc '0-9')
printf '{"used_pct": %s}\n' "$used"
EOF
sudo chmod 755 /etc/ansible/facts.d/rootfs.fact
/etc/ansible/facts.d/rootfs.fact

The last command runs the script directly. It should print one line of JSON. Test a fact script this way before Ansible runs it, because Ansible only reports a failure as a warning. Now read both files through setup:

ansible localhost -m ansible.builtin.setup -a 'filter=ansible_local'

Each file appears under ansible_local, keyed by its file name without .fact. Read them in a play as ansible_local['vps']['general']['role'] and ansible_local['rootfs']['used_pct']. Save this as local.yml:

- name: Read local facts
  hosts: localhost
  connection: local
  gather_facts: true
  gather_subset:
    - '!all'
  tasks:
    - name: Check that both local facts loaded
      ansible.builtin.assert:
        that:
          - ansible_local['vps']['general']['role'] is defined
          - ansible_local['rootfs']['used_pct'] is defined

This play gathers only !all and still sees the local facts, because local is part of min. INI keys are converted to lowercase on the way in, so a key written as Role must be read as role. If a script prints something that is neither JSON nor INI, setup warns error loading facts as JSON or ini - please check content and names the file. If a script exits with a non-zero code, the warning starts with Failure executing fact script. Keep static files at mode 644, so Ansible reads them instead of trying to run them.

Facts are gathered once, at the start of the play. If a task in the play writes a new .fact file, later tasks do not see it until you gather again. Reload only the local facts with a setup task:

    - name: Reload local facts after writing a new one
      ansible.builtin.setup:
        filter: ansible_local

Cache facts between runs with jsonfile

By default, facts live in memory and disappear when the run ends. The jsonfile cache plugin writes them to disk, one JSON file per host. Combined with gathering = smart, Ansible skips setup for any host that has a fresh cache entry. Replace ansible.cfg:

cat > ansible.cfg <<'EOF'
[defaults]
inventory = hosts.ini
gathering = smart
fact_caching = ansible.builtin.jsonfile
fact_caching_connection = ~/.ansible/fact_cache
fact_caching_timeout = 3600
EOF
install -d -m 700 ~/.ansible/fact_cache
ansible-config dump --only-changed

ansible-config dump --only-changed should list the gathering policy and the three cache settings, each with your ansible.cfg as its source. If they are missing, Ansible did not read the file. Check that you are inside ~/facts-lab. fact_caching_timeout is in seconds, so 3600 keeps a host's facts for one hour.

Run the play twice and look at the cache:

time ansible-playbook facts.yml
ls -l ~/.ansible/fact_cache/
time ansible-playbook facts.yml

The first run gathers and writes a file named localhost. The second run finds that file, skips setup for the host, and its real time drops by roughly the gathering cost you measured earlier. The cached facts also appear in hostvars for hosts that are not in the current play. A play on your web servers can read a database server's address from its cached facts without contacting it.

Two cautions come with a cache. First, cached facts describe the host as it was when they were cached. Add a disk or write a new local fact, and the cache keeps the old data until it expires. Refresh it on demand with ansible-playbook facts.yml --flush-cache. Second, the cache files are plain JSON, and a full gather includes the env subset, which holds the remote user's environment variables. That is why the cache directory above is mode 700. Keep it out of any shared or backed-up location you do not trust.

Choosing a setting

Leave gathering on for plays that branch on the OS or fill templates from facts. Use gather_subset when a play needs one group of facts, such as the network facts. Turn gathering off with gather_facts: false for a host without Python, and for plays that never read a fact. Add the jsonfile cache when you run the same playbooks many times a day against the same hosts.

FAQ

Is it safe to set gather_facts: false on every play?

It is safe for any play that does not read ansible_facts and does not use a role that does. If a task needs a fact in a play with gathering off, it fails with 'dict object' has no attribute followed by the fact name, because ansible_facts is empty in that play. Facts gathered by an earlier play in the same run are still available. To make off the default, set gathering = explicit in ansible.cfg and write gather_facts: true in the plays that need facts.

How do I run Ansible on a host that has no Python installed?

Set gather_facts: false on the play, because the hidden fact gathering task is a Python module and fails first with /usr/bin/python3: not found. Then install Python with ansible.builtin.raw, which sends a plain shell command and needs no Python on the host. After that, run an ansible.builtin.setup task to gather facts, and every later module works normally.

Does the setup filter make fact gathering faster?

No. The filter option runs every collector in full and then drops the facts that do not match the pattern, so it only makes the result smaller. To make gathering faster, use gather_subset, which decides which collectors run. Start from !all, which keeps a minimal set that includes distribution and local facts, and add back groups such as network or hardware.

How do I refresh cached Ansible facts?

Run the playbook with --flush-cache, which clears the fact cache for every host in the inventory, so the run gathers again. Without it, the jsonfile cache keeps each host's facts until fact_caching_timeout expires, which is 86400 seconds by default. Inside a play, a setup task with filter: ansible_local reloads local facts after you write a new .fact file.

Should I write ansible_distribution or ansible_facts['distribution']?

Write ansible_facts['distribution']. The top-level ansible_distribution form depends on the INJECT_FACTS_AS_VARS setting. Check its default and any deprecation notice on your own install with ansible-config dump | grep INJECT_FACTS_AS_VARS and ansible-config list. To find old lookups in a playbook, search it for fact names written with the ansible_ prefix, such as ansible_distribution, and rewrite each one as an ansible_facts lookup. Inventory variables such as ansible_host are not facts and stay as they are.