Variables, facts, and precedence
Where to declare variables, what facts are (ansible_facts), register, set_fact, and how Ansible resolves conflicts by precedence.
One playbook, several environments: dev listens on port 8080, prod on
443. Log paths differ, package versions differ. Copying the playbook for
every environment means editing the same thing in five places later.
Variables solve this. You write the logic once, and feed in the actual
values from outside depending on the environment. In this lesson we'll look
at where to declare variables, what facts are, how register and
set_fact work, and who wins when one variable is set in several places.
Where to declare variables
Ansible reads variables from many places. Here are the main ones.
vars: right in the play. The simplest way for local values:
- name: Deploy app
hosts: webservers
vars:
app_port: 8080
app_user: deploy
tasks:
- name: start service on port {{ app_port }}
...
vars_files:, an external YAML file. Handy when there are many
variables, or you want to reuse them across several plays:
vars_files:
- vars/common.yml
- vars/prod.yml
group_vars/ and host_vars/. Directories next to the inventory (or
the playbook). Ansible loads them automatically by group or host name.
Layout:
inventory/
hosts.ini
group_vars/
all.yml # for every host
webservers.yml # for the webservers group only
host_vars/
web01.yml # for host web01 only
The inventory directly. You can write variables right in hosts.ini or
hosts.yml, but it reads worse and only works for simple cases:
[webservers]
web01 ansible_user=deploy app_port=8080
web02 ansible_user=deploy app_port=8080
[webservers:vars]
nginx_worker_processes=4
defaults/main.yml and vars/main.yml in a role. We covered this in
detail in the roles lesson. In short: defaults has the lowest precedence
(easy to override from outside), vars has high precedence (the role's own
internal constants).
-e / --extra-vars on the command line. The highest precedence,
always wins. Handy for one-off overrides and CI scripts:
ansible-playbook deploy.yml -e "app_version=2.1.0 env=prod"
# or from a file
ansible-playbook deploy.yml -e "@overrides.yml"
set_fact. Sets a variable right during the play's run. More on this
below.
register. Saves a task's result into a variable. Also below.
Syntax: {{ var }} and YAML quoting
Ansible uses Jinja2 for variable substitution:
- name: create dir
file:
path: /opt/{{ app_name }}/data
state: directory
If a value starts with {{, YAML mistakes the curly braces for the
start of a dictionary, and the playbook fails before it even runs. Wrap the
whole value in quotes:
# BAD: YAML parsing error
dest: {{ app_dir }}/config.yml
# GOOD
dest: "{{ app_dir }}/config.yml"
ansible-core 2.19 prints this in that case:
[ERROR]: YAML parsing failed: This may be an issue with missing quotes around a template block.
The rule is simple: if a string starts with {{, quote it.
Facts: what Ansible knows about the host
Facts are variables that Ansible gathers about a host before running
tasks. The setup module is responsible for this. It runs automatically at
the start of every play, showing up in the output as the Gathering Facts
task.
The collected data lives in the ansible_facts dictionary. Commonly used
ones:
| Variable | What it holds |
|---|---|
ansible_facts['os_family'] | OS family: RedHat, Debian, ... |
ansible_facts['distribution'] | The specific distribution: Ubuntu, Rocky |
ansible_facts['default_ipv4']['address'] | IP of the main interface |
ansible_facts['memtotal_mb'] | Total RAM in megabytes |
ansible_facts['hostname'] | The host's hostname |
Many facts have short synonyms with the ansible_ prefix:
ansible_os_family, ansible_distribution, ansible_default_ipv4.address,
and so on. The docs call this the legacy access method, and you can turn
off this substitution with the INJECT_FACTS_AS_VARS setting. Writing
ansible_facts['...'] is more reliable.
See every fact for a host with one command:
ansible web01 -m setup
# or only the ones you need:
ansible web01 -m setup -a "filter=ansible_mem*"
If you don't need facts (saving time, or there's no Python on the host), turn them off:
- name: Quick ping
hosts: all
gather_facts: no
tasks:
- ping:
This is the same case we covered in the lesson about hosts without Python.
An example of using facts in a task:
- name: install Apache on Debian-based systems
apt:
name: apache2
state: present
when: ansible_facts['os_family'] == "Debian"
register: capture a task's result
register saves the full result of a task into a variable. You can then
use it in later tasks through when, debug, or templates.
What ends up in a registered variable:
.stdout— stdout output (a string);.stderr— stderr output;.rc— the return code;.changed— whether the host changed;.stdout_lines— stdout split into a list of lines.
Example:
- name: check if app is running
command: systemctl is-active myapp
register: app_status
ignore_errors: yes
- name: show status
debug:
msg: "App status: {{ app_status.stdout }}"
- name: start app if stopped
systemd:
name: myapp
state: started
when: app_status.rc != 0
ignore_errors: yes is needed here: systemctl is-active returns a
nonzero code (usually 3) if the service isn't active. Without this option
the task would fail before we get a chance to check the status.
The command module has no idea whether it changed anything, and always
reports changed. For a task that only reads, add changed_when: false,
or a repeat run will never show changed=0.
set_fact: set a variable on the fly
set_fact computes and sets a variable right during the play's run. The
variable is available in every later task for that host.
- name: set deploy path based on environment
set_fact:
deploy_path: "/opt/{{ app_name }}/{{ env }}"
config_path: "/etc/{{ app_name }}/{{ env }}.conf"
- name: create deploy dir
file:
path: "{{ deploy_path }}"
state: directory
By precedence, set_fact ranks high: above group_vars, play vars:, and
role vars/main.yml, but below -e. If the play's vars: sets
app_version: unknown, and then set_fact sets app_version: 1.4.0, the
rest of the play sees 1.4.0.
An interview detail: set_fact with cacheable: true creates two copies of
the variable. One is the regular copy, with set_fact's high precedence,
and it's the one that works for the rest of the current run. The second
goes into the fact cache and has facts' low precedence (below play vars).
The second copy only matters if fact caching is enabled and the variable is
read in later runs.
Variable precedence
When the same variable is defined in several places, Ansible takes the value from the source with the highest precedence and ignores the rest. There's no merging by default: one source wins.
(The hash_behaviour=merge option in ansible.cfg changes this for
dictionaries, but the docs don't recommend it. Don't use it in new
projects.)
A simplified ladder from lowest to highest:
The practical rule: -e always wins, role defaults always loses.
The diagram above is simplified. Within the second tier the sources also
have their own order: host_vars beats group_vars, and group_vars next
to the playbook beats group_vars from the inventory. The official list
has 22 items. The first one (command-line options like -u my_user) isn't
counted as a variable, so there are 21 variable tiers: from role defaults
to extra vars.
Official list: Variable precedence: Where should I put a variable?
How to find out what a host sees without a playbook:
ansible <host> -m debug -a var=<name> shows the value from the inventory
(group_vars, host_vars). If the value in the playbook is different, a
source higher up overrode it: vars:, vars_files:, a role, or
set_fact.
Common mistakes
- You set a variable in
group_vars, and it doesn't work. The same name sits somewhere higher up the ladder: a role'svars/main.yml, the play'svars:, orvars_files:. Search the whole project for the variable name (grep -rn app_port .). - You override a value in a role's
defaults, and it isn't picked up. The variable is already set in the inventory or in the play, anddefaultsloses to everything. - An unquoted
{{at the start of a value: the playbook fails parsing YAML. - You reference
ansible_facts[...]whilegather_facts: nois set: the variable is undefined. command+registerfor a read withoutchanged_when: false: every run showschanged, even though nothing on the host actually changed.
What you'll need for the tasks
Both labs run in a container with ansible-core 2.19.14, and Ansible manages
the same machine it runs on (ansible_connection=local). Files live in the
lab's work/ folder: edit them with your own editor, run them through
bash setup/run.sh.
ansible-playbook -i <inventory> <file>.ymland thePLAY RECAPline;ansible <host> -i <inventory> -m debug -a var=<name>to see the value from the inventory;vars:,vars_files:,group_vars/,host_vars/, and their order on the ladder;register,.stdout,set_fact,changed_when;ansible_facts['distribution']in a template.
In the next lesson we'll cover running safely in production: --check,
--diff, tags, and --limit.
Interview-style questions
Interview questions on this topic: 1
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson ansible-basics-08-variables-precedence
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress