Red Hat Certified Engineer (RHCE) #6: Variables and facts — precedence, magic vars, custom facts

10 min read

In #5 Playbook basics we got the hang of writing idempotent playbooks with tasks and handlers. But the playbooks up to that point had their values hardcoded. To handle different package names, ports, and paths per host with a single playbook, you need variables. And for a playbook to branch on conditions, it has to know the host’s own information (IP, memory, OS version) — the information that Ansible gathers automatically for you is called a fact.

This post lays out, from a hands-on angle, the several places where variables are defined and their precedence, how facts are gathered and used, register that holds a task result, magic variables that let you peer into the whole inventory, and the custom facts you build yourself.

Why you need variables #

When you run the same playbook against the web group and the db group, the package to install or the port to open differs. Writing the value directly into the task means a separate playbook per group. Variables collect the values in one place and let the task reference them by name, so the same playbook runs with different values per host.

Variable names use only letters, digits, and underscores, and don’t start with a digit. You can’t write http-port with a hyphen — use an underscore, like http_port. You may name variables freely on the exam, but if the question specifies a variable name, you must use that exact name or it won’t be graded.

Where variables are defined #

Ansible lets you define variables in several places. Here are the ones you use most in the hands-on exam.

1) A play’s vars #

The simplest method, written directly inside the playbook.

Play vars
---
- name: Define variables directly in the play
  hosts: web
  vars:
    http_port: 8080
    web_package: httpd
  tasks:
    - name: Install the package
      ansible.builtin.dnf:
        name: "{{ web_package }}"
        state: present

2) Referencing an external file with vars_files #

Pull the variables out into a separate file and load it from the playbook. This keeps things tidy as the variable count grows.

Load with vars_files
- name: Load variables from an external file
  hosts: web
  vars_files:
    - vars/web_vars.yml
  tasks:
    - name: Install the package
      ansible.builtin.dnf:
        name: "{{ web_package }}"
        state: present
vars/web_vars.yml
# vars/web_vars.yml
http_port: 8080
web_package: httpd

3) group_vars and host_vars #

These are the inventory-based variables covered in #2. Place the directories next to the playbook and Ansible reads them automatically.

Project layout
project/
├── inventory
├── playbook.yml
├── group_vars/
│   ├── all.yml      # common to all hosts
│   └── web.yml      # web group only
└── host_vars/
    └── node1.yml    # node1 host only
group_vars/web.yml
# group_vars/web.yml
http_port: 8080
web_package: httpd

When a value has to differ per host, host_vars takes precedence over group_vars. If the whole web group uses 8080 but only node1 should use 8443, you write http_port: 8443 in host_vars/node1.yml.

4) extra-vars on the command line #

Variables passed with -e (or --extra-vars) when running ansible-playbook. These are the top-priority variables that override every other definition.

Override with extra-vars
ansible-playbook playbook.yml -e "http_port=9090"
ansible-playbook playbook.yml -e "@vars/override.yml"

5) Saving a task result with register #

A way to capture a task’s execution result into a variable. We cover it separately below.

Variable reference syntax #

You reference a defined variable in the form {{ variable_name }}. The double braces are a Jinja2 expression, and #7 covers their use in detail.

Reference a variable
tasks:
  - name: Reference a variable
    ansible.builtin.debug:
      msg: "The port is {{ http_port }}"

When a value begins with a variable alone, YAML mistakes that line for an object, so you must wrap it in quotes.

Quote the value
# Wrong: braces immediately after the colon
- name: Error
  ansible.builtin.debug:
    msg: {{ http_port }}

# Right: wrapped in quotes
- name: OK
  ansible.builtin.debug:
    msg: "{{ http_port }}"

Dictionary values and list items are accessed with dot notation or bracket notation.

Access dict and list
vars:
  users:
    admin:
      shell: /bin/bash
  packages:
    - httpd
    - mariadb-server
tasks:
  - name: Access a dictionary and a list
    ansible.builtin.debug:
      msg: "{{ users.admin.shell }} / {{ packages[0] }}"

Variable precedence #

When a variable of the same name is defined in several places, the one with higher precedence wins. Ansible’s precedence rules are long, but the key thing to remember for the hands-on exam is that extra-vars (-e) always wins. Here are just the spots you run into often, in roughly that order.

PriorityVariable locationNote
Highextra-vars (-e)Overrides anything. Top priority
task varsThat task only
block varsBlock scope
role and include varsPassed at the role call
play vars / vars_filesThe whole play
host_varsHost only
group_vars (a specific group)Group only
group_vars/allCommon to all hosts
Lowrole defaultsWeakest. Meant to be overridden

You don’t need to memorize the full rules. For the exam, three things are enough: when you see “force this value to apply,” reach for extra-vars; host_vars beats group_vars; and role defaults are the weakest.

facts: gathering the host’s information #

A fact is system information that Ansible connects to the managed node and gathers automatically. It includes the IP, OS version, memory, CPU, disk, network interfaces, and more. You use these per-host values for conditional branching and templates.

gather_facts #

When a play starts, Ansible gathers facts by default. The Gathering Facts step you see in the output is exactly that. To turn gathering off, set gather_facts: false. Turning it off speeds up short playbooks that don’t need facts.

Disable fact gathering
- name: Turn off fact gathering
  hosts: web
  gather_facts: false
  tasks:
    - name: A task that uses no facts
      ansible.builtin.debug:
        msg: "Run it fast"

Checking facts with the setup module #

To see which facts exist, run the setup module ad-hoc. Internally, the setup module is what handles fact gathering.

Inspect facts with setup
ansible node1 -m setup
ansible node1 -m setup -a "filter=ansible_default_ipv4"
ansible node1 -m setup -a "filter=ansible_memory_mb"

Filtering down to just the fact you want lets you confirm the exact variable name, which saves time in the hands-on exam.

Accessing through ansible_facts #

Gathered facts go into the ansible_facts dictionary. The recommended form is ansible_facts['key']. Here are the facts you use often.

Fact referenceMeaning
ansible_facts['hostname']Hostname
ansible_facts['default_ipv4']['address']Default IPv4 address
ansible_facts['memtotal_mb']Total memory (MB)
ansible_facts['distribution']Distribution name (RedHat, etc.)
ansible_facts['distribution_major_version']Major version (9, etc.)
ansible_facts['processor_vcpus']Number of virtual CPUs
Use facts
- name: Use facts
  hosts: web
  tasks:
    - name: Print host information
      ansible.builtin.debug:
        msg: >-
          {{ ansible_facts['hostname'] }} /
          {{ ansible_facts['default_ipv4']['address'] }} /
          memory {{ ansible_facts['memtotal_mb'] }}MB

Old notation and new notation #

The old notation used flat variables with an ansible_ prefix, like ansible_hostname, ansible_default_ipv4.address, and ansible_memtotal_mb. The current recommendation is the ansible_facts['hostname'] form. Both work, so on the exam use whichever you’re comfortable with, but matching the notation shown in the docs is the safer choice.

register: saving a task result into a variable #

register captures a task’s execution result (return value, stdout, whether anything changed, etc.) into a variable for use in later tasks. You use it to branch on command output or pass it as input to the next task.

Use register
- name: Use register
  hosts: web
  tasks:
    - name: Query the service status
      ansible.builtin.command: systemctl is-active httpd
      register: httpd_status
      ignore_errors: true
      changed_when: false

    - name: Print the result
      ansible.builtin.debug:
        msg: "The httpd return code is {{ httpd_status.rc }}"

    - name: Branch on stdout
      ansible.builtin.debug:
        msg: "httpd is running"
      when: httpd_status.stdout == "active"

A registered variable holds several keys. Here are the ones you see often.

KeyMeaning
.rcThe command’s return code
.stdoutThe full standard output (string)
.stdout_linesStandard output as a list of lines
.changedWhether a change occurred (true/false)
.failedWhether it failed

When you don’t know which keys exist, print the registered variable whole with debug to check its structure.

Inspect register structure
- name: Check the structure of a registered variable
  ansible.builtin.debug:
    var: httpd_status

The details of conditional branching (when, loop) are covered in #9.

magic variables #

Magic variables are special variables that Ansible always provides without you defining them. You use them to peer into the whole inventory and the current execution context. They map directly to the exam pattern of “fetch another host’s IP and put it in the config.”

Magic variableMeaning
inventory_hostnameThe inventory name of the host being processed
hostvarsA dictionary that reaches every host’s variables and facts
groupsA mapping from group name to host list
group_namesThe list of groups the current host belongs to
ansible_play_hostsThe list of target hosts for this play
Use magic variables
- name: Use magic variables
  hosts: web
  tasks:
    - name: My own inventory name
      ansible.builtin.debug:
        msg: "I am {{ inventory_hostname }}"

    - name: Fetch another host's fact
      ansible.builtin.debug:
        msg: "node1's IP is {{ hostvars['node1']['ansible_facts']['default_ipv4']['address'] }}"

    - name: The host list of the db group
      ansible.builtin.debug:
        msg: "db members: {{ groups['db'] }}"

hostvars can only read another host’s value after that host has already gathered its facts. The safe approach is to scope your target group so that one play gathers every host’s facts first.

custom facts #

Beyond the built-in facts, custom facts are user-defined facts you plant directly on a host. Place a file with the .fact extension under /etc/ansible/facts.d/ on the managed node, and it’s read automatically during fact gathering and put into ansible_local.

A .fact file is written in INI format or JSON format. An INI example looks like this.

/etc/ansible/facts.d/custom.fact
# /etc/ansible/facts.d/custom.fact
[web]
package = httpd
port = 8080

[role]
tier = frontend

If this file is on node1, the gathered value is accessed through ansible_local. The structure is ansible_local['filename']['section']['key'].

Access custom facts
- name: Access a custom fact
  hosts: web
  tasks:
    - name: Print the local fact
      ansible.builtin.debug:
        msg: >-
          package {{ ansible_local['custom']['web']['package'] }} /
          tier {{ ansible_local['custom']['role']['tier'] }}

In the hands-on exam, deploying the custom fact file with a playbook sometimes comes up together. After placing the file in /etc/ansible/facts.d/ with the copy or template module, to use that value later in the same playbook you must re-gather facts with the setup module so that ansible_local reflects it at that point.

Deploy and re-gather
- name: Re-gather after deploying a custom fact
  hosts: web
  tasks:
    - name: Create the facts.d directory
      ansible.builtin.file:
        path: /etc/ansible/facts.d
        state: directory
        mode: '0755'

    - name: Deploy the custom fact file
      ansible.builtin.copy:
        src: files/custom.fact
        dest: /etc/ansible/facts.d/custom.fact
        mode: '0644'

    - name: Re-gather facts
      ansible.builtin.setup:

    - name: Verify the deployed fact
      ansible.builtin.debug:
        var: ansible_local['custom']

Exam points #

  • extra-vars (-e) is the top-priority variable. Reach for it when you see “force this value to apply.”
  • host_vars takes precedence over group_vars, group_vars/all is the broadest, and role defaults are the weakest.
  • Reference variables with {{ }}, and wrap them in quotes when a value begins with a variable.
  • Access facts with ansible_facts['key'], and check which facts exist with ansible node -m setup -a "filter=...".
  • The facts you use often are hostname, default_ipv4.address, memtotal_mb, and distribution_major_version.
  • Hold a task result with register, and branch the next task on .rc, .stdout, and .changed. When the structure confuses you, print it whole with debug.
  • Fetch another host’s value with hostvars['host']['...'], and get a group’s members with groups['groupname'].
  • Put custom facts in /etc/ansible/facts.d/*.fact and access them with ansible_local. To use them right after deployment, re-gather with the setup module.

Wrap-up #

What this post locked in:

  • Where variables are defined. The five branches of play vars, vars_files, group_vars/host_vars, extra-vars, and register
  • Precedence. extra-vars wins, host_vars takes precedence over group_vars, role defaults are the weakest
  • facts. Gathered automatically by gather_facts, accessed through ansible_facts, checked with the setup module
  • register. Hold a task result in a variable and branch on .rc, .stdout, and .changed
  • magic variables. Peer into the whole inventory with inventory_hostname, hostvars, and groups
  • custom facts. Read facts.d’s .fact files with ansible_local, and re-gather after deployment

We’ve laid the groundwork for handling per-host values with variables and facts. Now we move on to processing those values to generate configuration files dynamically.

Next: Jinja2 templates #

Now that we’ve gathered variables and facts, it’s time to combine those values to build a different config file per host.

In #7 Jinja2 templates: filters, control flow, lookup, we lay out — with hands-on examples — the template module and .j2 files, variable output and filters (default, upper, join, etc.), generating repeated blocks with for/if control flow, and pulling in external data with lookup.

X