Variables, facts & precedence
Where values come from and who wins.
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.
# 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.comweb02.example.com[webservers:vars]ansible_user=deploy
# group_vars/webservers.yml : applies to every host in the groupapp_env: stagingnginx_port: 8080tls_enabled: false # TLS is the encryption behind https# group_vars/dc_east.yml : same variable name, different groupnginx_port: 8443# host_vars/web01.example.com.yml : one host onlyapp_env: canary
- name: Configure the web tierhosts: webserversgather_facts: true # on unless your ansible.cfg says otherwisevars: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 abovetasks:- name: Capture the running kernelansible.builtin.command: uname -rregister: kernel_out # the module's result becomes a variablechanged_when: false # a read-only command should never report changed- name: Pin one value for the rest of the runansible.builtin.set_fact:deploy_id: "{{ app_env }}-{{ kernel_out.stdout }}"- name: Show the value that actually reached the hostansible.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.
# 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'
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.
[app]release=2026.07.3owner=payments-teamtier=pci
$ ansible web01.example.com -i inventory.ini -m ansible.builtin.setup \-a 'filter=ansible_local'
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.
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.
# Question 1: what does the INVENTORY resolve to for this host?$ ansible-inventory -i inventory.ini --host web01.example.com
{"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.
# Question 2: what does the TASK actually receive?$ ansible-playbook -i inventory.ini site.yml --limit web01.example.com
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.
# 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}'
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.
# See the membership that causes the merge$ ansible-inventory -i inventory.ini --graph
@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.
[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 belowansible_group_priority=10[webservers]web01.example.comweb02.example.com[webservers:vars]ansible_user=deploy
$ ansible-inventory -i inventory.ini --host web01.example.com
{"ansible_group_priority": 10,"ansible_user": "deploy","app_env": "canary","nginx_port": 8443,"tls_enabled": false}
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.
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.
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.