Red Hat Certified Engineer (RHCE) #6: Variables and facts — precedence, magic vars, custom facts
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.
---
- 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: present2) 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.
- 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
http_port: 8080
web_package: httpd3) 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/
├── 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
http_port: 8080
web_package: httpdWhen 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.
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.
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.
# 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.
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.
| Priority | Variable location | Note |
|---|---|---|
| High | extra-vars (-e) | Overrides anything. Top priority |
| ↑ | task vars | That task only |
| ↑ | block vars | Block scope |
| ↑ | role and include vars | Passed at the role call |
| ↑ | play vars / vars_files | The whole play |
| ↑ | host_vars | Host only |
| ↑ | group_vars (a specific group) | Group only |
| ↑ | group_vars/all | Common to all hosts |
| Low | role defaults | Weakest. 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.
- 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.
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 reference | Meaning |
|---|---|
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 |
- 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'] }}MBOld 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.
- 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.
| Key | Meaning |
|---|---|
.rc | The command’s return code |
.stdout | The full standard output (string) |
.stdout_lines | Standard output as a list of lines |
.changed | Whether a change occurred (true/false) |
.failed | Whether it failed |
When you don’t know which keys exist, print the registered variable whole with debug to check its structure.
- name: Check the structure of a registered variable
ansible.builtin.debug:
var: httpd_statusThe 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 variable | Meaning |
|---|---|
inventory_hostname | The inventory name of the host being processed |
hostvars | A dictionary that reaches every host’s variables and facts |
groups | A mapping from group name to host list |
group_names | The list of groups the current host belongs to |
ansible_play_hosts | The list of target hosts for this play |
- 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
[web]
package = httpd
port = 8080
[role]
tier = frontendIf this file is on node1, the gathered value is accessed through ansible_local. The structure is ansible_local['filename']['section']['key'].
- 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.
- 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 withansible 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 withgroups['groupname']. - Put custom facts in
/etc/ansible/facts.d/*.factand access them withansible_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
.factfiles 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.