Skip to main content
RunBook Academy

AnsibleXIII · Variables and PrecedenceVariable sources

The precedence order, verified by experiment

Intermediate⏱ ~22 minansible-playbook

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

Not yet marked complete on this device.

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
1Command-line values (for example -u my_user) — these are not variables
2Role defaults — roles/<name>/defaults/main.yml
3Inventory file or script group vars
4Inventory group_vars/all
5Playbook group_vars/all
6Inventory group_vars/*
7Playbook group_vars/*
8Inventory file or script host vars
9Inventory host_vars/*
10Playbook host_vars/*
11Host facts and cached set_fact
12Play vars
13Play vars_prompt
14Play vars_files
15Role vars — roles/<name>/vars/main.yml
16Block vars (for tasks in the block only)
17Task vars (for that task only)
18include_vars
19Registered vars and set_fact
20Role and include_role params
21include params
22Extra 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.

Read-only / Safethe probe play
$ ansible-playbook play.yml
PLAY [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=0

Driving 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:

Read-only / Saferole defaults are scoped, until they are not
$ ansible-playbook play.yml
TASK [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

  1. 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?

  2. 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.

  3. Q3. Which principles explain most of the precedence ordering? Select all that apply.

  4. 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.