Ansible facts are system information collected from managed nodes—distribution, kernel, IP addresses, memory, mounts, and Python details. Playbooks use those values in when conditions, templates, and module arguments without hard-coding every OS difference.
This guide explains how fact gathering works, how to inspect facts with setup, which common fact keys to reach for, how to disable gathering for speed, and how custom facts under /etc/ansible/facts.d/ appear as ansible_local. It assumes Ansible variables and ad hoc commands. For register output and magic variables see the register and magic variables guide—not repeated here.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
What are Ansible Facts?
Facts are variables Ansible collects from each managed node. They describe the machine state: operating system, hostname, network layout, hardware summary, and installed Python.
After gathering, you reference them like any variable—{{ ansible_distribution }}, {{ ansible_memtotal_mb }}—without defining them in group_vars. Facts refresh when setup or gather_facts runs again.
Why Facts Matter in Ansible Playbooks
Facts let one playbook adapt across distros and hardware:
- Pick
dnfvsaptusingansible_os_family - Branch on
ansible_virtualization_rolefor VM-only tasks - Template config with the host’s real IP from
ansible_default_ipv4 - Skip work when
ansible_mountsshows a filesystem already present
Without facts you duplicate playbooks per OS or maintain long static variable files that drift from reality.
How Ansible Gathers Facts
By default, each play runs implicit fact gathering before your first task. Ansible calls the ansible.builtin.setup module (same as gather_facts: true). The playbook structure guide shows where gather_facts sits at play level.
You can turn gathering off per play, change global policy in ansible.cfg, or call setup manually when you need facts later. Custom facts in /etc/ansible/facts.d/ are merged during the same setup run.
Gather Facts with the setup Module
Ad hoc fact collection against one host:
ansible localhost -m setupThat returns a large ansible_facts dictionary. For everyday use, filter it.
Filtered OS facts:
ansible localhost -m setup -a 'filter=ansible_distribution*'Sample output:
localhost | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Rocky",
"ansible_distribution_major_version": "10",
"ansible_distribution_release": "Red Quartz",
"ansible_distribution_version": "10.2"
},
"changed": false
}Rocky and 10.2 are what playbooks expose as ansible_distribution and ansible_distribution_version.
View Gathered Facts
List one fact name:
ansible localhost -m setup -a 'filter=ansible_kernel'Sample output:
localhost | SUCCESS => {
"ansible_facts": {
"ansible_kernel": "6.12.0-211.16.1.el10_2.0.1.x86_64"
},
"changed": false
}Inside a play, setup has already run (unless disabled), so use debug to inspect:
- name: Show distribution facts
ansible.builtin.debug:
msg: "{{ ansible_distribution }} {{ ansible_distribution_version }} on {{ ansible_kernel }}"Common Ansible Facts
Fact names start with ansible_. Group them mentally by topic—exact keys vary slightly by platform.
Operating system facts
| Fact | Example use |
|---|---|
ansible_distribution |
Rocky, Ubuntu |
ansible_distribution_version |
10.2 |
ansible_os_family |
RedHat, Debian |
ansible_kernel |
Running kernel string |
Network facts
ansible localhost -m setup -a 'filter=ansible_default_ipv4'Sample output:
"ansible_default_ipv4": {
"address": "10.0.2.15",
"interface": "enp0s3",
"gateway": "10.0.2.2"
}Use ansible_default_ipv4.address in templates when the host should bind to its primary IPv4.
Memory and CPU facts
ansible localhost -m setup -a 'filter=ansible_memtotal_mb'Sample output:
"ansible_memtotal_mb": 3909Related keys include ansible_processor_vcpus and ansible_processor_cores—filter with ansible_processor* when tuning JVM or worker counts.
Disk and mount facts
ansible_mounts is a list of dicts—device, mount point, fstype, size:
ansible localhost -m setup -a 'filter=ansible_mounts'Pick the root mount in a task:
when: item.mount == '/'
loop: "{{ ansible_mounts }}"Python facts
ansible localhost -m setup -a 'filter=ansible_python*'Sample output:
"ansible_python": {
"executable": "/usr/bin/python3",
"version": {
"major": 3,
"minor": 12,
"micro": 13
}
}Ansible uses this to choose the remote Python interpreter.
Use Fact Filters
filter accepts shell-style globs. Narrow output while exploring:
ansible localhost -m setup -a 'filter=ansible_eth*'In playbooks, call setup with filters when you only need a subset:
- name: Gather only distribution facts
ansible.builtin.setup:
filter: ansible_distribution*Filtering makes output smaller and easier to inspect. For performance tuning, combine filters with gather_subset, gather_facts: false, or fact caching when appropriate.
Use Facts in Playbooks
Facts behave like variables once gathering completes:
---
- name: Facts in playbook demo
hosts: localhost
connection: local
gather_facts: true
tasks:
- name: Show OS family
ansible.builtin.debug:
msg: "Package family={{ ansible_os_family }} on {{ ansible_distribution }} {{ ansible_distribution_version }}"Sample output:
ok: [localhost] => {
"msg": "Package family=RedHat on Rocky 10.2"
}Map ansible_os_family to package names, paths, or service modules instead of hard-coding rocky in every play.
Use Facts in when Conditions
Use fact fields in when without extra {{ }} around the whole expression:
- name: Run Red Hat family task
ansible.builtin.debug:
msg: "Would pick package for {{ ansible_os_family }}"
when: ansible_os_family == "RedHat"More operators and patterns: conditionals. This section only shows fact-driven branches.
Use Facts in Templates
Templates can reference facts directly:
# templates/motd.j2
Host {{ ansible_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}.
Primary IPv4: {{ ansible_default_ipv4.address | default('N/A') }}Use default() when a fact may be missing on some platforms. Full Jinja syntax: templates guide—only fact placeholders are shown here.
Disable Fact Gathering
Disable facts for one play
- name: Quick play without facts
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Facts are not defined yet
ansible.builtin.debug:
msg: "{{ ansible_hostname }}"Sample output:
fatal: [localhost]: FAILED! => {"msg": "... 'ansible_hostname' is undefined ..."}With gather_facts: false, fact variables do not exist until you call setup.
Gather facts manually with setup
- name: Manual fact gathering
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Gather distribution facts only
ansible.builtin.setup:
filter: ansible_distribution*
- name: Show distribution
ansible.builtin.debug:
msg: "{{ ansible_distribution }} {{ ansible_distribution_version }}"Sample output:
ok: [localhost] => {
"msg": "Rocky 10.2"
}When disabling facts is useful
- Short plays that only copy one file or restart one service
- Large inventories where fact time dominates
- Networks where
setupis slow or flaky and you supply variables in inventory instead
Global default in ansible.cfg:
[defaults]
gathering = explicitWith explicit, plays must set gather_facts: true or call setup when they need facts.
Fact Gathering Performance
Every setup run executes many small probes on the managed node. On hundreds of hosts, that adds minutes before your first real task.
Mitigations:
gather_facts: falsewhen facts are unusedsetupwithfilter:to reduce returned output, orgather_subset:when you want to limit which fact groups are collectedgathering = explicitinansible.cfgfor controlled runs- Fact caching (JSON file or Redis) for repeated playbook runs—configure in
ansible.cfgwhen the same fleet is automated frequently
Example of limiting collected groups while keeping the default minimum facts:
- name: Gather network and hardware subsets
ansible.builtin.setup:
gather_subset:
- network
- hardwareThis article mentions caching cost only; deep cache tuning belongs in a dedicated performance guide.
Facts vs Variables vs Magic Variables
| Kind | Source | Examples |
|---|---|---|
| Facts | Discovered on managed node via setup |
ansible_os_family, ansible_mounts |
| Variables | You define in inventory, playbooks, roles | web_port, app_env |
| Magic variables | Ansible runtime context | inventory_hostname, hostvars |
| Register | Prior task output | result.stdout, result.rc |
Facts describe node state. Variables carry policy you write in inventory and group_vars. Magic variables expose inventory and play machinery alongside registered task output. Variable precedence covers collisions between user-defined layers—not repeated here.
What are Custom Facts?
Custom facts (local facts) are extra key/value data you place on managed nodes, usually under /etc/ansible/facts.d/. The setup module reads them and exposes them under ansible_local.
Use them for static metadata Ansible cannot discover—application tier, maintenance window, rack label—or for values a short script generates at gather time.
Create Custom Facts with facts.d
On the managed node (or with Ansible file/copy tasks):
/etc/ansible/facts.d/
├── demo-app.fact
└── python_version.factCreate the directory:
sudo mkdir -p /etc/ansible/facts.dStatic JSON file (demo-app.fact):
{
"app_tier": "web",
"deploy_env": "lab"
}sudo cp demo-app.fact /etc/ansible/facts.d/
sudo chmod 644 /etc/ansible/facts.d/demo-app.factKeep static files readable but not executable—executable .fact files are treated as scripts.
Access Custom Facts with ansible_local
Gather and filter:
ansible localhost -m setup -a 'filter=ansible_local'Sample output:
localhost | SUCCESS => {
"ansible_facts": {
"ansible_local": {
"demo-app": {
"app_tier": "web",
"deploy_env": "lab"
}
}
},
"changed": false
}The dictionary key matches the filename without .fact (demo-app). Access in playbooks:
msg: "Tier is {{ ansible_local['demo-app'].app_tier }}"Use bracket notation when the key contains hyphens.
Custom Facts: Static Files vs Executable Scripts
| Type | File | Permissions | Content |
|---|---|---|---|
| Static | appmeta.fact |
Readable, not executable (644) | JSON or INI key/value |
| Executable | python_version.fact |
Executable (755) | Script printing JSON to stdout |
Executable example:
#!/bin/bash
python_ver=$(python3 --version 2>&1 | awk '{print $2}')
cat <<EOF
{"python_version": "${python_ver}"}
EOFsudo cp python_version.fact /etc/ansible/facts.d/
sudo chmod 755 /etc/ansible/facts.d/python_version.factAfter setup, read ansible_local.python_version.python_version. Invalid JSON or non-executable scripts when Ansible expects executables produce empty or missing ansible_local keys.
Refresh Facts After Creating Custom Facts
If a play creates or updates files in facts.d, facts in memory are stale until setup runs again:
- name: Deploy custom fact file
ansible.builtin.copy:
src: files/demo-app.fact
dest: /etc/ansible/facts.d/demo-app.fact
mode: "0644"
become: true
- name: Refresh facts after custom fact change
ansible.builtin.setup:
filter: ansible_local
- name: Use new custom fact
ansible.builtin.debug:
msg: "{{ ansible_local['demo-app'].deploy_env }}"Same refresh applies after package installs that change ansible_mounts or other discovered data.
Common Facts and Custom Facts Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
gather_facts: false but using ansible_* |
Undefined variable error | Enable gathering or call setup |
| Wrong fact name after upgrade | Empty or undefined key | Re-run ansible -m setup and search current keys |
| Missing nested key | Template error on ansible_mounts[0].uuid |
Use default() or test with when: var is defined |
| Custom script not executable | Key missing from ansible_local |
chmod +x the .fact script |
| Invalid JSON from script | Empty ansible_local section |
Validate script stdout with jq manually |
Static .fact marked executable |
Ansible runs it as a script | Keep static JSON/INI at mode 644, not +x |
Expect ansible_local before copy |
Old values in play | Run setup after changing facts.d |
| Confuse fact with inventory var | Wrong value after edit | Facts refresh on setup; inventory vars come from YAML files |
Recommended Usage
In a small Ansible project:
- Leave default
gather_facts: truewhile learning soansible_*facts exist in every play. - Use
ansible -m setup -a 'filter=...'to find exact fact names before wiring templates. - Store fleet-wide policy in
group_vars; use facts for discovered OS and hardware data. - Add custom facts sparingly under
/etc/ansible/facts.d/for metadata Ansible cannot infer. - Disable gathering or filter
setupwhen plays are short and facts are unused.
References
- Ansible: Using variables — facts
- setup module — filters and fact paths
- Ansible: Local facts (facts.d)
Summary
Ansible facts are discovered variables from managed nodes—OS, network, memory, mounts, Python, and more—collected by setup, usually at play start. Use filter to inspect them with ad hoc commands or partial gathering. Disable with gather_facts: false when you do not need them. Custom facts live in /etc/ansible/facts.d/ and appear under ansible_local. Refresh with setup after you add or change custom facts. Facts describe the node; inventory variables and magic variables serve different roles in the same playbook.

