AnsibleXIII · Variables and PrecedenceVariable sources
Every place a variable can come from
What you'll learn
- Enumerate every source Ansible can take a variable value from
- Group those sources by owner rather than by precedence rank
- Read a repository and state which sources may legitimately set a given variable
- Recognise a variable defined in two places as a design defect, not a precedence question
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
An Ansible variable is a name bound to a value for one host during one run. Twenty-two different places can supply that binding, and Ansible resolves conflicts silently, with no warning and no record of which source won.
That is the whole problem this part of the course exists to solve. The usual response is to memorise the precedence table, and the table is worth knowing — the next lesson establishes it by experiment. But precedence is a tie-break, and a tie-break only matters once you have a tie. The more useful skill, and the one that prevents incidents rather than explaining them afterwards, is being able to open a repository and answer a different question:
Which places are entitled to set this variable, and did anyone set it somewhere they should not have?
A well-built estate answers that by inspection. Every variable has one obvious home, a second definition is a review finding, and precedence never has to arbitrate anything. An estate that needs the precedence table to explain its behaviour has already lost the property that makes automation reviewable.
So this lesson catalogues the sources by owner — who writes them, where they live, and what review path they go through — rather than by rank.
The four owners
Every variable source belongs to one of four groups. The grouping is not Ansible’s; it is an operational one, based on who edits the file and what has to happen before that edit reaches production.
| Owner | Sources | Lives in | Changed by |
|---|---|---|---|
| Inventory | inventory file vars, group_vars/, host_vars/ | the inventory tree | an infrastructure change, reviewed |
| Role | defaults/main.yml, vars/main.yml | the role | a role change, reviewed |
| Play | vars, vars_files, vars_prompt, role params | the playbook | a playbook change, reviewed |
| Runtime | facts, set_fact, register, include_vars, --extra-vars | nowhere durable | whatever happened during the run |
Read that last row carefully. Runtime sources are the ones that do not exist in the repository at all, or exist only as a value computed while the run was in progress. They are the sources that make an estate unreadable, and — as the precedence lesson shows — they sit at the top of the ladder, where they overrule everything a reviewer looked at.
Inventory-owned sources
The inventory describes your infrastructure, so it is the right home for anything that is a property of the infrastructure: which environment a host is in, how big it is, which datacentre it sits in, what it is for.
There are three inventory-owned shapes, and they are not interchangeable.
Variables in the inventory file itself. Set inline, either per host or per group:
[web]
web-01.example.com
web-02.example.com
[web:vars]
webapp_listen_port=443
This form is compact and it is the lowest-priority inventory source.
It is fine for a handful of values in a small estate. It scales badly:
the file becomes a mix of topology and configuration, and values written
as key=value are all strings unless the inventory plugin converts
them.
group_vars/<group>.yml. One file per group, keyed by group name.
This is where the overwhelming majority of an estate’s configuration
belongs, and the next few lessons argue that case in detail.
host_vars/<host>.yml. One file per host. This is for genuine
exceptions — the one machine with different hardware, the one host
excluded from a policy — and nothing else.
Role-owned sources
A role ships two variable files, at nearly opposite ends of the precedence ladder. The distance between them is deliberate and it is an interface decision.
roles/<name>/defaults/main.yml holds defaults. It is the lowest
precedence source in the entire system — lower than anything in the
inventory — which means anyone can override it. A value here says: this
is a sensible starting point, and callers are expected to change it.
roles/<name>/vars/main.yml holds role vars. These sit above the
inventory and above play vars. A value here says: this is internal to
the role, and callers should not be changing it.
Putting a value in the wrong one of those two files is not a filing error, it is an interface change. Lesson 5 is entirely about that decision.
Play-owned sources
The playbook can set variables directly:
- name: configure the web tier
hosts: web
vars:
deploy_window: '02:00-04:00'
vars_files:
- vars/release-pins.yml
roles:
- role: webapp
webapp_worker_count: 8
Three sources appear there. vars is inline play vars. vars_files
loads a file at play scope. The webapp_worker_count passed alongside
the role is a role parameter, which is a much higher-precedence
source than either — high enough to overrule the inventory, which
surprises people who think of it as “just setting a variable for the
role”.
vars_prompt is the fourth play-owned source. It asks the operator at
run time, which makes it unusable in any non-interactive context, so it
belongs in a small number of deliberately manual playbooks and nowhere
near a scheduled job.
Runtime-owned sources
These are the ones with no durable home.
- Facts — gathered from the managed node by the
setupmodule. Everything underansible_facts, plus the injectedansible_*names. You do not write these; the host reports them. set_fact— a variable computed during the run and attached to the host for the rest of the play.register— the result of a task, stored under a name you choose.include_vars— a file loaded by a task rather than by the play, which is why it ranks abovevars_filesdespite doing a similar job.--extra-vars/-e— supplied on the command line. Beats everything, records nothing.
Facts and register are covered in Part XIV. What matters here is their
position: every runtime source outranks every reviewed source. A value
established at run time overrules a value someone approved in a pull
request, and that asymmetry is the reason the design lessons in this
part are prescriptive rather than advisory.
Reading a repository
Here is the habit the rest of this part depends on. Before asking what a
variable resolves to, find every place it is defined. grep is the
right tool and it is the first one to reach for:
$ grep -rn 'webapp_worker_count' --include='*.yml' ../inventory/group_vars/all.yml:2:webapp_worker_count: 4
./inventory/group_vars/prod.yml:1:webapp_worker_count: 16
./inventory/host_vars/web-02.example.com.yml:1:webapp_worker_count: 2
./roles/webapp/defaults/main.yml:2:webapp_worker_count: 1
./roles/webapp/tasks/main.yml:3: msg: "port={{ webapp_listen_port }} workers={{ webapp_worker_count }} log={{ webapp_log_level }}"Four definitions and one use. Now the ownership question has an answer you can evaluate:
roles/webapp/defaults/main.yml— the role’s default. Correct.inventory/group_vars/all.yml— an estate-wide baseline. Defensible.inventory/group_vars/prod.yml— production policy. Correct.inventory/host_vars/web-02.example.com.yml— a per-host exception. Correct if somebody can say why that host is different.
That is a healthy layout: every definition is inventory-owned or role-owned, each has a review path, and a reader can predict the outcome without running anything. The estate above is the one used throughout this part, and here is what it actually resolves to:
$ ansible-inventory --host web-02.example.com{
"ansible_connection": "local",
"webapp_listen_port": 443,
"webapp_log_level": "warn",
"webapp_worker_count": 2
}webapp_worker_count is 2: the host_vars exception beat the prod
group policy of 16, which beat the all baseline of 4. The role
default of 1 does not appear at all, because ansible-inventory only
knows about the inventory.
That last point is the single most important limitation of this command,
and lesson 3 returns to it. ansible-inventory answers “what does the
inventory say”, not “what will the play see”.
Dictionaries do not merge
One behaviour surprises people who expect layering to be additive. When the same variable holds a dictionary and is defined at two levels, the higher-precedence definition replaces the lower one entirely. It does not merge key by key.
# inventory/group_vars/all.yml
webapp_limits:
memory: 512M
cpu: 1
# inventory/group_vars/prod.yml - replaces the whole dict
webapp_limits:
memory: 4G
On a production host webapp_limits is {memory: 4G}. The cpu key is
gone, not inherited. ansible-config reports the setting responsible:
$ ansible-config list | grep -A 3 '^DEFAULT_HASH_BEHAVIOUR'DEFAULT_HASH_BEHAVIOUR:
choices:
merge: Any dictionary variable will be recursively merged with new definitions
across the different variable definition sources.
replace: Any variable that is defined more than once is overwritten using the
order from variable precedence rules (highest wins).The default is replace, and upstream’s own description of the merge
alternative carries the warning that changing it “is not recommended as
this is fragile”. Take that seriously: merge is a controller-wide
setting that changes the meaning of every dictionary variable in every
role you use, including roles you did not write. If you need per-key
layering, do it explicitly in the place that needs it with the combine
filter, where the behaviour is visible in the code rather than in a
config file nobody reads.
The catalogue, condensed
Keep this shape in your head; the next lesson supplies the exact ordering.
- Lowest of everything: role
defaults/. - Then the inventory, in the order group before host, and
inventory-adjacent before playbook-adjacent, with
allbefore named groups. - Then the play — facts, play
vars,vars_prompt,vars_files. - Then the role internals and task scope — role
vars/, block vars, task vars. - Then everything decided at run time —
include_vars,set_fact, registered results, role params, include params. - Above all of it:
--extra-vars.
The shape is more useful than the list, because it tells you the direction of the asymmetry: the further a value is from a reviewed file in the repository, the more power it has. That is a design constraint you have to work against deliberately, and the next seven lessons are about how.
Knowledge check
Knowledge check · 4 questions
Q1. You open an unfamiliar estate and need to know what webapp_listen_port will be on a production host. What is the correct first step?
Q2. Which of these variable sources leave no durable record in the repository? Select all that apply.
Q3. When the same dictionary variable is defined in two group_vars files, Ansible merges the keys, so a key present only in the lower-precedence file survives.
Q4. What does the four-owner grouping - inventory, role, play, runtime - tell you that the precedence table does not?
Passing score: 75%. Answers are checked in this browser.