AnsibleXIII · Variables and PrecedenceVariable sources
The precedence order, verified by experiment
What you'll learn
- State the precedence order from role defaults up to extra vars
- Explain the three principles that generate the order rather than memorising it
- Reproduce the ordering yourself with a read-only debug play
- Apply the role defaults scoping caveat to tasks inside and outside a role
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
This is the lesson with the table in it. It is one lesson of eight, which is roughly the weight the table deserves.
The list below is not transcribed from memory or from a blog post. It
was established by running ansible-core 2.21.3: the same variable was
defined at every source simultaneously, a read-only debug play
reported the winner, that source was removed, and the play was run
again. Repeating that until no sources remained produces the ordering as
an observation rather than an assertion. The method is described in
full below, and you can reproduce it in a scratch directory in about ten
minutes.
That distinction matters more than it sounds. Variable precedence is exactly the kind of claim that is easy to state confidently and get subtly wrong, and a subtly wrong precedence table is worse than no table — it produces confident predictions that fail in production.
The order, lowest to highest
| # | Source |
|---|---|
| 1 | Command-line values (for example -u my_user) — these are not variables |
| 2 | Role defaults — roles/<name>/defaults/main.yml |
| 3 | Inventory file or script group vars |
| 4 | Inventory group_vars/all |
| 5 | Playbook group_vars/all |
| 6 | Inventory group_vars/* |
| 7 | Playbook group_vars/* |
| 8 | Inventory file or script host vars |
| 9 | Inventory host_vars/* |
| 10 | Playbook host_vars/* |
| 11 | Host facts and cached set_fact |
| 12 | Play vars |
| 13 | Play vars_prompt |
| 14 | Play vars_files |
| 15 | Role vars — roles/<name>/vars/main.yml |
| 16 | Block vars (for tasks in the block only) |
| 17 | Task vars (for that task only) |
| 18 | include_vars |
| 19 | Registered vars and set_fact |
| 20 | Role and include_role params |
| 21 | include params |
| 22 | Extra vars (-e) — always win |
Twenty-one of those twenty-two were confirmed by execution on
ansible-core 2.21.3 for this lesson. The exception is entry 1, which
upstream itself flags as “not variables” — -u my_user sets a
connection option, not a variable binding, so there is nothing to
observe a precedence contest against.
How the order was established
The method is worth understanding because it is the same method you should use whenever you are unsure — and because it costs almost nothing.
Build a scratch tree that defines one variable at every source at once,
each with a value naming its own source. Point a play at a host with
ansible_connection=local and gather_facts: false, and have it do
nothing but debug the variable. Run it: the reported value names the
winning source. Delete that source. Run again. The sequence of winners
is the precedence order, discovered rather than assumed.
$ ansible-playbook play.ymlPLAY [precedence probe] ********************************************************
TASK [demo : inside the role] **************************************************
ok: [web1] => {
"msg": "INROLE=extra_vars"
}
TASK [outside the role] ********************************************************
ok: [web1] => {
"msg": "OUTROLE=extra_vars"
}
PLAY RECAP *********************************************************************
web1 : ok=2 changed=0 unreachable=0 failed=0Driving that loop over the thirteen sources that can all coexist at play scope produces this, top to bottom:
rank winner [sources still defined]
----------------------------------------------------------
1 extra_vars [13]
2 role_vars [12]
3 vars_files [11]
4 play_vars [10]
5 pb_host_vars_file [9]
6 inv_host_vars_file [8]
7 inv_file_host_vars [7]
8 pb_group_vars_group [6]
9 inv_group_vars_group [5]
10 pb_group_vars_all [4]
11 inv_group_vars_all [3]
12 inv_file_group_vars [2]
13 role_defaults [1]
Read bottom-up, that is entries 2 through 15 of the table, in exactly the documented order.
The task-scoped sources cannot all be tested at play scope, because
block vars, task vars and include params only exist around a
particular task. A second probe — a task reached through
include_role, wrapped in a block, with its own task vars, after an
include_vars and a set_fact — covers the rest:
rank winner [sources still defined]
----------------------------------------------------------
1 extra_vars [9]
2 include_params [8]
3 role_params [7]
4 set_fact [6]
5 include_vars [5]
6 task_vars [4]
7 block_vars [3]
8 role_vars [2]
9 role_defaults [1]
Again, exactly the documented order — entries 15 through 22.
Two further checks fill the remaining gaps. vars_prompt (13) was
confirmed to beat play vars (12) and to lose to -e; supplying the
variable with -e suppresses the prompt entirely. And host facts (11)
were confirmed to beat host_vars (10) and lose to play vars (12), by
defining ansible_hostname in host_vars and watching the gathered
fact overwrite it, then watching play vars overwrite the fact.
The three principles that generate the order
Memorising twenty-two rows is a poor use of memory, and the list is not arbitrary. Three principles produce nearly all of it.
More recent wins. Sources are applied in order and later ones
overwrite earlier ones. set_fact at task time beats a value loaded at
play time; include_vars beats vars_files because it happens later.
More active wins. A value you deliberately supplied beats a value
that was merely lying in a file. Role params beat role vars; -e beats
everything, because nothing is more deliberate than typing it on the
command line.
Narrower scope wins. A source that applies to fewer things beats one
that applies to more. Host beats group; a named group beats all; task
vars beat block vars beat play vars.
Those three explain the shape. Where they conflict, the two structural
exceptions are the ones worth learning individually: role defaults/ is
pinned to the bottom regardless of scope, and role vars/ sits high
regardless of how passive it looks.
The role defaults scoping caveat
Upstream states it in two sentences that are easy to skim past:
Tasks in each role see their own role’s defaults. Tasks defined outside of a role see the last role’s defaults.
The second sentence is the surprising one, and it is real. A play that
runs two roles which both define the same key in defaults/main.yml
behaves like this:
$ ansible-playbook play.ymlTASK [alpha : alpha sees] ******************************************************
ok: [web1] => {
"msg": "ALPHA role_scoped=alpha_default"
}
TASK [zulu : zulu sees] ********************************************************
ok: [web1] => {
"msg": "ZULU role_scoped=zulu_default"
}
TASK [outside any role] ********************************************************
ok: [web1] => {
"msg": "OUTSIDE role_scoped=zulu_default"
}Each role saw its own default. The post_tasks task, which belongs to
no role, saw zulu_default — because zulu was listed second.
The operational consequence: a task outside a role that reads a
role-default name has its value decided by role ordering in the play.
Reorder the roles: list for an unrelated reason and the value changes,
with nothing in the diff to suggest it would.
The defence is not to remember the rule. It is never to read a role-owned default from outside that role. If a value is needed by both a role and the play around it, it belongs in the inventory, where both can see it and neither owns it.
Where the table is genuinely useful
Three situations, and they are all diagnostic rather than design.
Explaining an observed value. You have a wrong value, you have
found the definitions with grep, and you need to know which one is
responsible. This is the table’s actual job.
Judging whether a source is appropriate. “Can I override this from
the inventory?” is a precedence question. If the value lives in role
vars/, the answer is no — you would need -e, which is the wrong
answer for anything routine.
Reviewing an override. Someone adds host_vars for a host that a
role param already sets. The table tells you the new file will do
nothing, which is a review comment worth making before it ships.
What the table is not useful for is designing a variable layout. A design that requires the reader to consult it has already failed the test the next lessons set out.
Knowledge check
Knowledge check · 4 questions
Q1. A role sets webapp_port in roles/webapp/vars/main.yml. An operator adds webapp_port to inventory/group_vars/prod.yml to change it in production. What happens?
Q2. A task defined outside any role that reads a variable set only in a role defaults/main.yml gets its value from whichever role was listed last in the play.
Q3. Which principles explain most of the precedence ordering? Select all that apply.
Q4. You want to establish precedence behaviour on a version you have just upgraded to, without touching any managed host. What is the appropriate method?
Passing score: 75%. Answers are checked in this browser.