CoursesAnsibleVariables, facts & precedence

Variables, facts & precedence

Where values come from and who wins.

Intermediate14 min · lesson 5 of 12

Your office building has a default temperature. Your floor has its own. Your team's room overrides that. And when somebody walks up and jabs the wall panel by hand, their number beats all three, whatever the building manager set. Ansible variables behave the same way. One name can be set, legally and on purpose, in a dozen places at once, and there is a single fixed rulebook deciding which value actually reaches the machine you are configuring. Learn the rulebook and a whole family of bugs stops happening: the wrong port, staging credentials in production, the hardening role that quietly did nothing because something further up the ladder overruled it.

Every Door A Value Can Walk Through

Variables get into a play through a lot of doors, and none of them announce themselves. The first two are tied to your inventory (the file listing the machines Ansible manages and the groups they belong to). Drop a YAML file (YAML is the indented plain-text format Ansible uses for nearly everything) into a directory called group_vars/ and name it after a group, and every host in that group picks up its contents. Do the same in host_vars/ named after a single host, and only that host gets it. You never write an import line anywhere. Ansible matches these by filename, which is lovely right up to the day you typo one and spend an hour wondering why nothing happened.

Inside the play you get four more doors. vars: sets values inline. vars_files: pulls them from a separate file when the play starts. vars_prompt: stops and asks a human at the keyboard. And ansible.builtin.set_fact computes a value in the middle of a run. (Those dotted names are fully qualified collection names, the modern way to name a module so there is no doubt which collection it came from.) Tasks add register:, which catches a module's result and parks it in a variable you can branch on later. Roles bring two more: defaults/main.yml and vars/main.yml, which sit at opposite ends of the ladder for reasons the roles lesson covers. On the command line, -e (short for --extra-vars) can inject anything at all. The number of doors is not the trap. The trap is that one name like app_env can arrive through four of them in a single run, and Ansible will not say a word about it.

inventory.ini
# web01 belongs to TWO top-level groups. That is the whole point of this file.
# dc_east is written first here deliberately: file order decides nothing.
[dc_east]
web01.example.com
[webservers]
web01.example.com
web02.example.com
[webservers:vars]
ansible_user=deploy
group_vars/ and host_vars/
# group_vars/webservers.yml : applies to every host in the group
app_env: staging
nginx_port: 8080
tls_enabled: false # TLS is the encryption behind https
# group_vars/dc_east.yml : same variable name, different group
nginx_port: 8443
# host_vars/web01.example.com.yml : one host only
app_env: canary
site.yml
- name: Configure the web tier
hosts: webservers
gather_facts: true # on unless your ansible.cfg says otherwise
vars:
app_env: production # play vars outrank host_vars. Yes, really.
vars_files:
- vars/tunables.yml # e.g. nginx_worker_processes: 4
# loaded at play start, outranks the vars: block above
tasks:
- name: Capture the running kernel
ansible.builtin.command: uname -r
register: kernel_out # the module's result becomes a variable
changed_when: false # a read-only command should never report changed
- name: Pin one value for the rest of the run
ansible.builtin.set_fact:
deploy_id: "{{ app_env }}-{{ kernel_out.stdout }}"
- name: Show the value that actually reached the host
ansible.builtin.debug:
var: app_env

Facts: What The Host Says About Itself

Facts are the form each machine fills in about itself before any real work starts. Name, version, memory, network cards. With gather_facts: true, Ansible runs the ansible.builtin.setup module on every target first and gets back a large dictionary: distribution and version, kernel, CPU count, memory, every network interface, mounted filesystems, which package manager is installed, which service manager is running. All of it lands under ansible_facts, so ansible_facts['distribution'] comes back as Ubuntu or RedHat. That is how one playbook installs software with apt on one host and dnf on another without you maintaining two copies of anything.

Gathering is not free. It costs a full round trip plus a few seconds of work on every target, so across a thousand hosts it can dominate the run. Two knobs shrink it and they do genuinely different jobs. Picture sending somebody into a warehouse: gather_subset decides how many aisles they walk, filter decides how many of the photos they mail back to you. Only the first one saves anyone any work, which is why adding filter=ansible_default_ipv4 on its own makes a run no faster at all. And gather_subset=network does not mean "only network". The parameter defaults to all, and naming a subset adds to that default instead of replacing it. To genuinely cut collection you subtract first: !all,!min, then add back the one subset you want. Be careful with that: the min subset carries the small, cheap facts including ansible_local, so cutting it takes those away too. The network subset also quietly drags in the platform and distribution collectors it depends on. You still skip the slow parts, hardware and mounts, and mounts is the one that hangs for thirty seconds on a dead network filesystem.

terminal
# WRONG: the host still collects every fact, then the module trims the reply
$ ansible web01.example.com -i inventory.ini -m ansible.builtin.setup \
-a 'filter=ansible_default_ipv4'
# RIGHT: subtract all and min first, then add back only the subset you need
$ ansible web01.example.com -i inventory.ini -m ansible.builtin.setup \
-a 'gather_subset=!all,!min,network filter=ansible_default_ipv4'
output
web01.example.com | SUCCESS => {
"ansible_facts": {
"ansible_default_ipv4": {
"address": "10.20.4.11",
"alias": "eth0",
"broadcast": "10.20.4.255",
"gateway": "10.20.4.1",
"interface": "eth0",
"macaddress": "52:54:00:8a:1c:3e",
"mtu": 1500,
"netmask": "255.255.255.0",
"network": "10.20.4.0",
"prefix": "24",
"type": "ether"
}
},
"changed": false
}

Both commands print exactly that. The output is identical. The work the host had to do to produce it is not, and on a big inventory that difference is most of your run time.

You can also make a host report something Ansible could never discover on its own. Any file ending in .fact under /etc/ansible/facts.d/ on the managed host is read during gathering. It can be an INI file (the old key=value format with [section] headers), a JSON file (JavaScript Object Notation, a machine-readable text format), or an executable that prints JSON to standard output. The contents show up under ansible_local, keyed by the filename with the .fact dropped. Teams use this to tag a box with its owner, its change window, or its compliance tier, then branch on that inside a play.

/etc/ansible/facts.d/deploy.fact
[app]
release=2026.07.3
owner=payments-team
tier=pci
terminal
$ ansible web01.example.com -i inventory.ini -m ansible.builtin.setup \
-a 'filter=ansible_local'
output
web01.example.com | SUCCESS => {
"ansible_facts": {
"ansible_local": {
"deploy": {
"app": {
"owner": "payments-team",
"release": "2026.07.3",
"tier": "pci"
}
}
}
},
"changed": false
}

Two details worth catching there. Every value came back as a string, because that is all an INI file can hold: "2026.07.3" is text, not a number. And tier=pci is a label somebody typed, short for the payment card industry security standard. Hold on to that second one, because it comes back to bite in the security section.

The Ladder, Floor To Ceiling

Ansible settles every collision with one fixed, published list of 22 levels, from role defaults on the floor to extra vars at the ceiling. You do not need all 22 memorised. You do need the shape, because two of the rungs sit somewhere almost everybody guesses wrong.

Variable precedence, floor to ceiling
1role defaults
defaults/main.yml, built to be overridden
2inventory vars
group_vars first, then host_vars on top
3host facts
setup output plus cached set_fact
4play & role vars
vars:, vars_prompt:, vars_files:, role vars/
5runtime vars
block, task, include_vars, set_fact, register
6call site & CLI
role params, then -e, which beats everything
Each box overrides the one on its left. The rung people get wrong is host facts: gathered facts lose to play vars, while set_fact and register sit two boxes higher and win.

First surprise: gathered facts rank below play vars. A vars: block in your play overrides a fact of the same name. Harmless, until somebody names a variable after a fact. Second surprise: set_fact is nowhere near the facts rung. Gathered facts sit at level 11. set_fact and registered results sit at level 19, above play vars and above a role's own vars/main.yml. That is why a value you set_fact sticks for the rest of the play in a way an inventory value never does. There is a third, quieter one. Within a single play, vars_files: outranks the vars: block three lines above it, so a file loaded at play start silently replaces the inline value you can read on screen.

The overall shape is worth saying in plain words. Role defaults/ is the floor, deliberately, so anyone using your role can change it from their own inventory without touching your code. Inventory sits one rung up, with host_vars beating group_vars, and a group_vars/ directory next to your playbook beating a group_vars/ directory next to your inventory file. Play-level and role-internal values come next. Anything computed at task time lands above those. Values handed to a role at the point you call it (role params, written as plain key: value lines directly under - role: web) sit at level 20, higher even than set_fact. And -e beats every one of them, with no way to override it from inside a playbook. Encryption changes none of this. A group_vars/prod/vault.yml encrypted with ansible-vault is decrypted as it loads and competes at exactly the same rung as the plaintext file sitting beside it.

Proving Who Won

Never argue with Ansible about what a variable equals. Ask it. There are two separate questions here, they need two different commands, and mixing them up costs people entire afternoons.

terminal
# Question 1: what does the INVENTORY resolve to for this host?
$ ansible-inventory -i inventory.ini --host web01.example.com
output
{
"ansible_user": "deploy",
"app_env": "canary",
"nginx_port": 8080,
"tls_enabled": false
}

Read that slowly. app_env came back canary, because host_vars beats group_vars, exactly as you would hope. But nginx_port came back 8080, the webservers value, even though dc_east also claims web01 and sets 8443. Nothing in the file declared webservers more important. Hold that thought.

ansible-inventory only ever shows you inventory-sourced values. It knows nothing about facts, play vars:, set_fact, role variables, or -e. It will also miss a group_vars/ directory living next to your playbook rather than next to your inventory file, unless you point it there with --playbook-dir. For the value a task actually receives, you have to run the play.

terminal
# Question 2: what does the TASK actually receive?
$ ansible-playbook -i inventory.ini site.yml --limit web01.example.com
output
PLAY [Configure the web tier] **************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01.example.com]
TASK [Capture the running kernel] **********************************************
ok: [web01.example.com]
TASK [Pin one value for the rest of the run] ***********************************
ok: [web01.example.com]
TASK [Show the value that actually reached the host] ***************************
ok: [web01.example.com] => {
"app_env": "production"
}
PLAY RECAP *********************************************************************
web01.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

ansible-inventory said canary. The task received production. Both answers are correct. The play's vars: block outranks host_vars, so the host-specific value never got anywhere near the task. That single discrepancy is the most common "why is this the wrong value" ticket you will ever be handed, and those two commands close it in about thirty seconds.

terminal
# A one-off override during an incident. Nothing on disk changes.
$ ansible-playbook -i inventory.ini site.yml --limit web01.example.com -e app_env=hotfix
# Same top precedence, but from a reviewed file
$ ansible-playbook -i inventory.ini site.yml -e "@overrides.yml"
# JSON form, so you can pass real types instead of everything-is-a-string
$ ansible-playbook -i inventory.ini site.yml -e '{"nginx_port": 9443, "tls_enabled": true}'
output
TASK [Show the value that actually reached the host] ***************************
ok: [web01.example.com] => {
"app_env": "hotfix"
}

The Silent Group Merge

Back to nginx_port. Two people write on the same whiteboard, one after the other, and the second one wins. That is all a group merge is. web01 belongs to webservers and to dc_east, both top-level groups, both setting the same key. Ansible has no idea what your group names mean, so it cannot rank them by which one sounds more specific. It merges groups at the same depth in plain ASCII order (alphabetical byte order) and lets the last one written win. dc_east sorts before webservers, so webservers writes last and the port stays 8080. No warning, no note in the log, nothing in the output hinting another value ever existed. Shuffling the groups around in the file changes nothing either, because the sort is on the group name, not the line number.

terminal
# See the membership that causes the merge
$ ansible-inventory -i inventory.ini --graph
output
@all:
|--@ungrouped:
|--@dc_east:
| |--web01.example.com
|--@webservers:
| |--web01.example.com
| |--web02.example.com

There are two fixes. The clean one is to keep any given key in exactly one place, so there is nothing to merge to begin with. When you genuinely need overlapping groups (a role group and a datacenter group, say), set ansible_group_priority on whichever group should be authoritative. It defaults to 1. A higher number merges later and therefore wins, and equal numbers fall back to alphabetical order exactly as before.

inventory.ini
[dc_east]
web01.example.com
[dc_east:vars]
# merge dc_east last, so its keys win
# note: this comment is on its own line on purpose, see the warning below
ansible_group_priority=10
[webservers]
web01.example.com
web02.example.com
[webservers:vars]
ansible_user=deploy
terminal
$ ansible-inventory -i inventory.ini --host web01.example.com
output
{
"ansible_group_priority": 10,
"ansible_user": "deploy",
"app_env": "canary",
"nginx_port": 8443,
"tls_enabled": false
}
ansible_group_priority only works in the inventory source, and INI comments bite
Set ansible_group_priority in the inventory file itself, in a [group:vars] block or the vars: key of a YAML inventory. Putting it in group_vars/dc_east.yml does nothing at all, because Ansible needs the priority while it is deciding how to load group_vars/ in the first place. This one fails silently and convincingly: the file looks right, the variable even shows up in ansible-inventory --host, and the merge order never budges. Second trap in the same file: the INI parser only strips comments that start at the beginning of a line. Write ansible_user=deploy # the deploy account and the value becomes the literal string "deploy # the deploy account", quietly breaking every SSH connection in that group. Keep INI comments on their own lines.

Where This Turns Into A Security Problem

Two properties of this system carry real weight for security, and both cut in either direction. The first is that -e cannot be beaten from inside a playbook. That is deliberate and genuinely useful at three in the morning, because you can force a value without editing a reviewed file or waiting on a merge. It also means the extra-vars string is exactly as privileged as the playbook itself. Connection settings are ordinary variables, which makes them overridable too. Pass -e ansible_host=... and you retarget the run at a machine of the caller's choosing. Pass -e ansible_connection=local and every supposedly remote task runs on the control node instead (the control node is the machine you launched ansible-playbook from), so a playbook written to reconfigure a web server now reconfigures the box holding your SSH keys and your vault password. SSH is Secure Shell, the encrypted remote login protocol Ansible rides on. Pass -e ansible_ssh_common_args='-o ProxyCommand=...' and you hand SSH a command to run locally before it even connects. A CI job (continuous integration, the robot that runs your pipelines) with a free-text "extra vars" box is remote code execution wearing a form field, and remote code execution means running commands of somebody else's choosing on your machine.

So treat that box the way you treat any shell input. Allow a fixed list of keys, or drop the free-text field entirely and pass -e "@vars/prod.yml" from a file that lives in the reviewed repository. Remember too that anything you hand to -e shows up in the process list on the control node, where any other local user can read it, and in the CI job log, where it is kept for months. That makes the command line the wrong place for a password, however convenient it looks. Encrypt the file with ansible-vault and pass the file.

The second property is that facts are claims, not proof. They are a form the host filled in about itself, not an identity check at the door. Every value under ansible_facts was produced by code that ran on the managed host, and ansible_local is read straight out of files under /etc/ansible/facts.d/ that root on that host can rewrite in a second. A play that says when: ansible_local.deploy.app.tier != 'pci' to skip an expensive hardening step has handed a compromised host a one-line opt-out from being hardened. Use facts to adapt: which package manager, which interface, how much memory. Use inventory, which you control and review in git, to decide who gets which policy.

Facts land in the same namespace as your variables
By default Ansible copies every gathered fact into the top-level variable namespace as well, so you get both ansible_facts['distribution'] and the shorter ansible_distribution. Convenient, and a collision waiting to happen: facts outrank group_vars and host_vars, so any inventory variable you named with an ansible_ prefix that happens to match a gathered fact is silently replaced by whatever the host reported. Setting inject_facts_as_vars = False under [defaults] in ansible.cfg confines facts to the ansible_facts dictionary and removes that whole class of bug. The cost is real: every ansible_distribution in your playbooks and roles, including third-party roles you pulled with ansible-galaxy, then has to become ansible_facts['distribution'] or it comes back undefined.

One habit covers most of this. When a value surprises you, run ansible-inventory -i <inventory> --host <hostname> first, then a one-task play with ansible.builtin.debug and var: <name> second. If the two agree, the bug is in your inventory and you have three files to open. If they disagree, the winner sits somewhere between rung 11 and rung 22, which narrows it to a play vars: block, a vars_files: entry, a set_fact, a role param, or an -e on somebody's command line. Five places to look, not twenty.

Quick check
01The same variable is set in a role's defaults/main.yml, in group_vars/web.yml, in a play's vars: block, and passed with -e on the command line. Which value reaches the task?
Incorrect — Play vars do beat group_vars and role defaults, but they lose to extra vars, and proximity in the file has nothing to do with precedence.
Incorrect — Inventory vars sit near the bottom of the ladder, above only role defaults, precisely so they stay overridable.
Correct — extra vars are level 22 of 22 and cannot be overridden from inside a playbook at all.
Incorrect — defaults/ is deliberately the lowest tier so anyone calling the role can change it from their own inventory.
02You add filter=ansible_default_ipv4 to the setup module hoping to speed up fact gathering across 500 hosts, and the run takes exactly as long as before. Why?
Correct — filtering happens after collection, so it saves bandwidth and output noise but no work on the target.
Incorrect — filter works normally with gathering enabled, and turning gathering off would skip the setup module entirely rather than change how filter behaves.
Incorrect — The setup module returns keys already prefixed with ansible_, so ansible_default_ipv4 is a valid shell-style pattern exactly as written.
Incorrect — The default fact cache is in-memory and lasts one run; persistent caching needs a cache plugin, and a cache is per host anyway, so it could never flatten a 500-host run.
03ansible-inventory --host web01 reports nginx_port: 8080, but you set nginx_port: 8443 in group_vars/dc_east.yml and web01 is definitely in dc_east. Both dc_east and webservers are top-level groups. What is going on, and what fixes it?
Incorrect — Child groups sit deeper and therefore merge later and override their parents, not the other way round, and nothing here makes dc_east a child of anything.
Incorrect — Group size has no bearing on variable precedence; only depth, priority, and name order do.
Incorrect — ansible-inventory does read group_vars/ adjacent to the inventory; what it misses is playbook-adjacent group_vars unless you pass --playbook-dir.
Correct — dc_east sorts before webservers, the later merge wins silently, and a higher ansible_group_priority set in the inventory file itself flips the order.

Try this

Run ansible-inventory -i inventory.ini --host web01.example.com on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: ansible_group_priority only works in the inventory source, and INI comments bite. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related