Ansible Facts and Custom Facts Explained

Learn Ansible facts from the setup module—OS, network, memory, mounts, and Python—plus fact filters, gather_facts control, custom facts.d files, and ansible_local in playbooks and templates.

Published

Updated

Read time 9 min read

Reviewed byDeepak Prasad

Ansible facts and custom facts with setup module, ansible_local, and facts.d on Rocky Linux

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.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/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 dnf vs apt using ansible_os_family
  • Branch on ansible_virtualization_role for VM-only tasks
  • Template config with the host’s real IP from ansible_default_ipv4
  • Skip work when ansible_mounts shows 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:

bash
ansible localhost -m setup

That returns a large ansible_facts dictionary. For everyday use, filter it.

Filtered OS facts:

bash
ansible localhost -m setup -a 'filter=ansible_distribution*'

Sample output:

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:

bash
ansible localhost -m setup -a 'filter=ansible_kernel'

Sample output:

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:

yaml
- 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

bash
ansible localhost -m setup -a 'filter=ansible_default_ipv4'

Sample output:

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

bash
ansible localhost -m setup -a 'filter=ansible_memtotal_mb'

Sample output:

output
"ansible_memtotal_mb": 3909

Related 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:

bash
ansible localhost -m setup -a 'filter=ansible_mounts'

Pick the root mount in a task:

yaml
when: item.mount == '/'
loop: "{{ ansible_mounts }}"

Python facts

bash
ansible localhost -m setup -a 'filter=ansible_python*'

Sample output:

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:

bash
ansible localhost -m setup -a 'filter=ansible_eth*'

In playbooks, call setup with filters when you only need a subset:

yaml
- 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:

yaml
---
- 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:

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:

yaml
- 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:

jinja2
# 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

yaml
- 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:

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

yaml
- 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:

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 setup is slow or flaky and you supply variables in inventory instead

Global default in ansible.cfg:

ini
[defaults]
gathering = explicit

With 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: false when facts are unused
  • setup with filter: to reduce returned output, or gather_subset: when you want to limit which fact groups are collected
  • gathering = explicit in ansible.cfg for controlled runs
  • Fact caching (JSON file or Redis) for repeated playbook runs—configure in ansible.cfg when the same fleet is automated frequently

Example of limiting collected groups while keeping the default minimum facts:

yaml
- name: Gather network and hardware subsets
  ansible.builtin.setup:
    gather_subset:
      - network
      - hardware

This 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):

text
/etc/ansible/facts.d/
├── demo-app.fact
└── python_version.fact

Create the directory:

bash
sudo mkdir -p /etc/ansible/facts.d

Static JSON file (demo-app.fact):

json
{
  "app_tier": "web",
  "deploy_env": "lab"
}
bash
sudo cp demo-app.fact /etc/ansible/facts.d/
sudo chmod 644 /etc/ansible/facts.d/demo-app.fact

Keep static files readable but not executable—executable .fact files are treated as scripts.


Access Custom Facts with ansible_local

Gather and filter:

bash
ansible localhost -m setup -a 'filter=ansible_local'

Sample output:

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:

yaml
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:

bash
#!/bin/bash
python_ver=$(python3 --version 2>&1 | awk '{print $2}')
cat <<EOF
{"python_version": "${python_ver}"}
EOF
bash
sudo cp python_version.fact /etc/ansible/facts.d/
sudo chmod 755 /etc/ansible/facts.d/python_version.fact

After 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:

yaml
- 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

In a small Ansible project:

  1. Leave default gather_facts: true while learning so ansible_* facts exist in every play.
  2. Use ansible -m setup -a 'filter=...' to find exact fact names before wiring templates.
  3. Store fleet-wide policy in group_vars; use facts for discovered OS and hardware data.
  4. Add custom facts sparingly under /etc/ansible/facts.d/ for metadata Ansible cannot infer.
  5. Disable gathering or filter setup when plays are short and facts are unused.

References


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.


Frequently Asked Questions

1. What are Ansible facts?

Facts are variables Ansible discovers on managed nodes—OS version, IP addresses, memory, mounts, Python path, and more. They are collected by the setup module, usually at play start.

2. How do I list facts for one host?

Run ansible HOST -m setup from the project root. Add filter=ansible_kernel or another fact name to limit output.

3. What is gather_facts: false for?

It skips automatic fact collection at play start when you do not need facts, which saves time on large inventories or quick checks.

4. Where do custom facts appear in Ansible?

Files or scripts under /etc/ansible/facts.d/ on the managed node show up under ansible_local after setup runs—for example ansible_local["myapp"]["tier"].

5. Do custom fact scripts need to be executable?

Executable .fact scripts must be chmod +x and print valid JSON to stdout. Static JSON or INI .fact files should be readable but not executable.

6. What is the difference between facts and inventory variables?

Facts are discovered from the managed node at runtime. Inventory variables are defined in inventory, group_vars, or host_vars before the play runs.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …